diff --git a/.devcontainer/cpu/devcontainer.json b/.devcontainer/cpu/devcontainer.json index 0f1a2195..f0357b1f 100644 --- a/.devcontainer/cpu/devcontainer.json +++ b/.devcontainer/cpu/devcontainer.json @@ -9,7 +9,7 @@ "remoteUser": "root", "workspaceFolder": "${localWorkspaceFolder}", "workspaceMount": "source=${localWorkspaceFolder},target=${localWorkspaceFolder},type=bind", - "postCreateCommand": "uv pip install -e .[cpu,dev]", + "postCreateCommand": "uv pip install -e .[onnx-cpu,dev]", "remoteEnv": { "UV_SYSTEM_PYTHON": "true" }, diff --git a/.devcontainer/cuda/devcontainer.json b/.devcontainer/gpu/devcontainer.json similarity index 96% rename from .devcontainer/cuda/devcontainer.json rename to .devcontainer/gpu/devcontainer.json index 3fac38c3..7b2a20a1 100644 --- a/.devcontainer/cuda/devcontainer.json +++ b/.devcontainer/gpu/devcontainer.json @@ -1,7 +1,7 @@ { "name": "Focoos GPU (OnnxRuntime, CUDA)", "build": { - "target": "focoos-cuda", + "target": "focoos-gpu", "context": "../..", "dockerfile": "../../Dockerfile", }, @@ -12,7 +12,7 @@ }, "workspaceFolder": "${localWorkspaceFolder}", "workspaceMount": "source=${localWorkspaceFolder},target=${localWorkspaceFolder},type=bind", - "postCreateCommand": "uv pip install -e .[cuda,dev]", + "postCreateCommand": "uv pip install -e .[onnx,dev]", "remoteEnv": { "UV_SYSTEM_PYTHON": "true" }, diff --git a/.devcontainer/tensorrt/devcontainer.json b/.devcontainer/tensorrt/devcontainer.json index e0f35b84..ab962c73 100644 --- a/.devcontainer/tensorrt/devcontainer.json +++ b/.devcontainer/tensorrt/devcontainer.json @@ -1,5 +1,5 @@ { - "name": "Focoos GPU (OnnxRuntime, Torch, TensorRT)", + "name": "Focoos GPU (OnnxRuntime, TensorRT)", "build": { "context": "../..", "dockerfile": "../../Dockerfile", diff --git a/.devcontainer/torch/devcontainer.json b/.devcontainer/torch/devcontainer.json deleted file mode 100644 index 2e99f344..00000000 --- a/.devcontainer/torch/devcontainer.json +++ /dev/null @@ -1,80 +0,0 @@ -{ - "name": "Focoos GPU (OnnxRuntime,Torch)", - "build": { - "context": "../..", - "dockerfile": "../../Dockerfile", - "target": "focoos-torch" - }, - "shutdownAction": "none", - "remoteUser": "root", - "hostRequirements": { - "gpu": "optional" - }, - "workspaceFolder": "${localWorkspaceFolder}", - "workspaceMount": "source=${localWorkspaceFolder},target=${localWorkspaceFolder},type=bind", - "postCreateCommand": "uv pip install -e .[torch,dev]", - "remoteEnv": { - "UV_SYSTEM_PYTHON": "true" - }, - "runArgs": [ - "--gpus=all", - "--ipc=host", - "--runtime=nvidia", - "--ulimit=memlock=-1", - "--ulimit=stack=67108864", - "--privileged" - ], - "postStartCommand": [ - "nvidia-smi" - ], - "features": { - "ghcr.io/devcontainers/features/common-utils:2": { - "installZsh": "true", - "configureZshAsDefaultShell": "true", - "username": "vscode", - "userUid": "1000", - "userGid": "1000", - "upgradePackages": "true" - }, - // git - "ghcr.io/devcontainers/features/git:1": { - "version": "os-provided", - "ppa": "false" - }, - // "ghcr.io/devcontainers/features/docker-outside-of-docker:1": {}, - "ghcr.io/iterative/features/nvtop:1": {}, - }, - "customizations": { - "devpod": { - "podManifestTemplate": ".devcontainer/pod_manifest.yaml" - }, - "vscode": { - "settings": { - "terminal.integrated.shell.linux": "/bin/bash", - "python.terminal.activateEnvInCurrentTerminal": true, - }, - "extensions": [ - "ms-python.python", - "ms-azuretools.vscode-docker", - "ms-toolsai.jupyter", - "ms-python.python", - "vscode.git", - "ms-azuretools.vscode-docker", - "vscode.ipynb", - "waderyan.gitblame", - "michelemelluso.gitignore", - "amazonwebservices.aws-toolkit-vscode", - "naumovs.color-highlight", - "mindaro-dev.file-downloader", - "donjayamanne.githistory", - "github.vscode-github-actions", - "seatonjiang.gitmoji-vscode", - "ms-vscode.remote-repositories", - "donjayamanne.python-environment-manager", - "ninoseki.vscode-pylens", - "ninoseki.vscode-mogami", - "charliermarsh.ruff" - ] - } - } -} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 538963e2..6ad8c365 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -5,8 +5,7 @@ on: env: UV_SYSTEM_PYTHON: 1 jobs: - Run-test: - + code-tests: permissions: id-token: write # This is required for requesting the JWT contents: read @@ -30,7 +29,7 @@ jobs: with: python-version: ${{ matrix.python-version }} - name: ๐Ÿ“ฆ Install dev dependencies - run: uv pip install .[cpu,dev] + run: uv sync --extra dev --extra onnx-cpu - name: ๐Ÿงช Run test run: make test - name: ๐Ÿ“Š Generate test coverage report @@ -40,3 +39,41 @@ jobs: pytest-xml-coverage-path: ./tests/coverage.xml junitxml-path: ./tests/junit.xml report-only-changed-files: true + hide-report: true + model-tests: + runs-on: actions-runner-cuda12 + steps: + - name: ๐Ÿ“ฅ Checkout Repository + uses: actions/checkout@v4 + - name: ๐Ÿ Setup Python + uses: actions/setup-python@v4 + with: + python-version: 3.12 + - name: ๐Ÿ Setup uv + uses: astral-sh/setup-uv@v4 + with: + python-version: "3.12" + enable-cache: true + cache-dependency-glob: "uv.lock" + - name: ๐Ÿ“ฆ Install dev dependencies + run: uv sync --extra dev --extra onnx + - name: ๐Ÿงช Run e2e tests for all models + run: | + echo "๐Ÿ” Discovering models in model registry..." + MODELS=$(find ./focoos/model_registry -maxdepth 1 -type f -name "*.json" | xargs -n1 basename | sed 's/\.json$//') + + if [ -z "$MODELS" ]; then + echo "โš ๏ธ No models found in registry" + exit 1 + fi + + echo "๐Ÿ“‹ Found models: $(echo "$MODELS" | tr '\n' ' ')" + + for model in $MODELS; do + echo "๐Ÿงช Testing model: $model" + uv run ops/test_training.py --model "$model" + done + + echo "โœ… All model tests completed successfully" + - name: ๐Ÿงน Minimize uv cache + run: uv cache prune --ci diff --git a/.gitignore b/.gitignore index 65400f8a..539bf848 100644 --- a/.gitignore +++ b/.gitignore @@ -91,4 +91,10 @@ notebooks/.data /data tests/junit.xml notebooks/datasets +notebooks/experiments site/ +/datasets/ +/examples/ +notebooks/test.ipynb +gradio/output/ +tutorials/experiments diff --git a/.python-version b/.python-version deleted file mode 100644 index e4fba218..00000000 --- a/.python-version +++ /dev/null @@ -1 +0,0 @@ -3.12 diff --git a/Dockerfile b/Dockerfile index d43f1e48..49ac803d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,36 +1,24 @@ FROM python:3.12-slim-bullseye AS focoos-cpu COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/ LABEL authors="focoos.ai" -RUN apt-get update && \ +RUN apt-get update --fix-missing && \ apt-get install -y --no-install-recommends \ build-essential git ffmpeg libsm6 libxext6 gcc libmagic1 wget make cmake WORKDIR /app COPY focoos ./focoos COPY pyproject.toml ./pyproject.toml -RUN uv pip install --system -e .[cpu] +RUN uv pip install --system -e .[onnx-cpu] -FROM ghcr.io/focoosai/deeplearning:base-cu12-cudnn9-py312-uv AS focoos-cuda +FROM ghcr.io/focoosai/deeplearning:base-cu12-cudnn9-py312-uv AS focoos-gpu LABEL authors="focoos.ai" WORKDIR /app COPY focoos ./focoos COPY pyproject.toml ./pyproject.toml -RUN uv pip install --system -e .[cuda] +RUN uv pip install --system -e .[onnx] -FROM focoos-cuda AS focoos-torch -RUN uv pip install --system -e .[torch] - -FROM focoos-torch AS focoos-tensorrt -RUN apt-get update && apt-get install -y \ - wget lsb-release && \ - wget https://developer.download.nvidia.com/compute/cuda/repos/ubuntu2204/x86_64/cuda-keyring_1.1-1_all.deb && \ - dpkg -i cuda-keyring_1.1-1_all.deb && \ - apt-get update && apt-get install -y \ - tensorrt \ - python3-libnvinfer-dev \ - uff-converter-tf && \ - apt-get clean && rm -rf /var/lib/apt/lists/* +FROM focoos-gpu AS focoos-tensorrt RUN uv pip install --system -e .[tensorrt] diff --git a/Makefile b/Makefile index 75a4c140..e4308ba0 100644 --- a/Makefile +++ b/Makefile @@ -10,18 +10,20 @@ venv: @uv venv --python=python3.12 install: .uv .pre-commit - @uv pip install -e ".[dev,docs]" --no-cache-dir + @uv sync --extra onnx --extra tensorrt --extra dev --extra docs @pre-commit install -install-gpu: .uv .pre-commit - @uv pip install -e ".[dev,cuda,tensorrt,torch,docs]" --no-cache-dir +install-cpu: .uv .pre-commit + @uv sync --extra onnx-cpu --extra dev --extra docs @pre-commit install + + docs: - @mkdocs build --clean + @uv run mkdocs build --clean serve-docs: - @mkdocs serve + @uv run mkdocs serve lint: @ruff check ./focoos ./tests ./notebooks --fix @@ -31,7 +33,7 @@ run-pre-commit: .pre-commit @pre-commit run --all-files test: - @pytest -s --cov=focoos --cov-report="xml:tests/coverage.xml" --cov-report=html --junitxml=./tests/junit.xml && rm -f .coverage + @uv run pytest -s tests --cov=focoos --cov-report=term-missing:skip-covered --cov-report="xml:tests/coverage.xml" --cov-report=html --junitxml=./tests/junit.xml && rm -f .coverage tox: tox diff --git a/README.md b/README.md index 3d53f618..3525a13a 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ ![Tests](https://github.com/FocoosAI/focoos/actions/workflows/test.yml/badge.svg??event=push&branch=main) - +[![Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/FocoosAI/focoos/blob/main/tutorials/training.ipynb) # Welcome to Focoos AI Focoos AI provides an advanced development platform designed to empower developers and businesses with efficient, customizable computer vision solutions. Whether you're working with data from cloud infrastructures or deploying on edge devices, Focoos AI enables you to select, fine-tune, and deploy state-of-the-art models optimized for your unique needs. @@ -7,94 +7,96 @@ Focoos AI provides an advanced development platform designed to empower develope ## SDK Overview -This powerful SDK gives you seamless access to our cutting-edge computer vision models and tools, allowing you to effortlessly interact with the Focoos API. With just a few lines of code, you can easily **select, customize, test, and deploy** pre-trained models tailored to your specific needs. +The Focoos Python SDK is your gateway to easily access cutting-edge computer vision models and development tools. With just a few lines of code, you can **fine tune** pre-trained models tailored to your specific needs. -Whether you're deploying in the cloud or on edge devices, the Focoos Python SDK integrates smoothly into your workflow, speeding up your development process. +Whether you're working in the cloud or on edge devices, the Focoos Python SDK seamlessly integrates into your workflow, accelerating development and simplifying the implementation of computer vision solutions. ### Key Features ๐Ÿ”‘ -1. **Select Ready-to-use Models** ๐Ÿงฉ +1. **Frugal Pretrained Models** ๐ŸŒฟ Get started quickly by selecting one of our efficient, [pre-trained models](https://focoosai.github.io/focoos/models/) that best suits your data and application needs. + Focoos Model Registry give access to 11 pretrained models of different size from different families: RTDetr, Maskformer, BisenetFormer -2. **Personalize Your Model** โœจ - Customize the selected model for higher accuracy through [fine-tuning](https://focoosai.github.io/focoos/how_to/cloud_training/). Adapt the model to your specific use case by training it on your own dataset. - -3. **Test and Validate** ๐Ÿงช - Upload your data sample to [test the model](https://focoosai.github.io/focoos/how_to/inference/)'s accuracy and efficiency. Iterate the process to ensure the model performs to your expectations. +2. **Fine Tune Your Model** โœจ Adapt the model to your specific use case by customize its config and training it on your own dataset. -4. **Remote and Local Inference** ๐Ÿ–ฅ๏ธ - Deploy the model on your devices or use it on our servers. Download the model to run it locally, without sending any data over the network, ensuring full privacy. +4. **Optimized Inference** ๐Ÿ–ฅ๏ธ Export Models and run inference efficiently, Leverage hardware acceleration through Torchscript, TensorRT and ONNX for maximum performance. +5. **FocoosHub Integration** ๐Ÿ”„ Seamlessly integrate with Focoos Cloud to access your models and data, you can also run cloud inference on managed models. -## Quickstart ๐Ÿš€ +# Quickstart ๐Ÿš€ Ready to dive in? Get started with the setup in just a few simple steps! -### Installation +## Installation **Install** the Focoos Python SDK (for more options, see [setup](https://focoosai.github.io/focoos/setup)) -**uv** ```bash linenums="0" uv pip install 'focoos @ git+https://github.com/FocoosAI/focoos.git' ``` -**pip**, **conda** -```bash linenums="0" -pip install 'focoos @ git+https://github.com/FocoosAI/focoos.git' -``` +## Inference + +```python +from focoos.model_registry import ModelRegistry +from focoos.model_manager import ModelManager + +image_path = "./image.jpg" -### Inference -[![Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/FocoosAI/focoos/blob/main/notebooks/inference.ipynb) +model = ModelManager.get("fai-detr-l-obj365") # any models from ModelRegistry, FocoosHub or local folder -๐Ÿš€ [Directly use](https://focoosai.github.io/focoos/how_to/inference/) our **Efficient Models**, optimized for different data, applications, and hardware. +detections = model(image_path) +``` +## Training ```python -from focoos import Focoos -from PIL import Image +from focoos.data.default_aug import get_default_by_task +from focoos.ports import TrainerArgs, Task +from focoos.data.auto_dataset import AutoDataset +from focoos.model_manager import ModelManager +from focoos.ports import DatasetSplitType, DatasetLayout, RuntimeType -# Initialize the Focoos client with your API key -focoos = Focoos(api_key="") +ds_name = "my_dataset.zip" +task = Task.DETECTION +layout = DatasetLayout.ROBOFLOW_COCO -# Get the remote model (fai-rtdetr-m-obj365) from Focoos API -model = focoos.get_remote_model("fai-rtdetr-m-obj365") +auto_dataset = AutoDataset(dataset_name=ds_name, task=task, layout=layout) -# Run inference on an image -detections, preview = model.infer(image_path, annotate=True) +train_augs, val_augs = get_default_by_task(task, 640, advanced=False) +train_dataset = auto_dataset.get_split(augs=train_augs.get_augmentations(), split=DatasetSplitType.TRAIN) +valid_dataset = auto_dataset.get_split(augs=val_augs.get_augmentations(), split=DatasetSplitType.VAL) -# Output the detections -Image.fromarray(preview) -``` -### Training -[![Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/FocoosAI/focoos/blob/main/notebooks/training.ipynb) +model = ModelManager.get("fai-detr-l-obj365") -โš™๏ธ **Customize** the models to your specific needs by [fine-tuning](https://focoosai.github.io/focoos/how_to/cloud_training/) on your own dataset. +args = TrainerArgs( + run_name=f"{ds_name}-{model.model_info.name}", + batch_size=16, + max_iters=50, + eval_period=50, + learning_rate=0.0008, + sync_to_hub=False, # use this to sync model info, weights and metrics on the hub +) +model.train(args, train_dataset, valid_dataset) +``` + + +## Export and optimized Inference ```python -from focoos import Focoos -from focoos.ports import Hyperparameters - -focoos = Focoos(api_key="") -model = focoos.new_model(name="awesome", - focoos_model="fai-rtdetr-m-obj365", - description="An awesome model") - -res = model.train( - dataset_ref="", - hyperparameters=Hyperparameters( - learning_rate=0.0001, - batch_size=16, - max_iters=1500, - ) -) -``` +from focoos.model_manager import ModelManager +from focoos.ports import RuntimeType -See more examples in the [how to](https://focoosai.github.io/focoos/how_to) section. -## Our Models ๐Ÿง  +model = ModelManager.get("fai-detr-l-obj365") +infer_model = model.export(runtime_type=RuntimeType.TORCHSCRIPT_32) +infer_model.benchmark() + +``` + +# Our Models ๐Ÿง  Focoos AI offers the best models in object detection, semantic and instance segmentation, and more is coming soon. Using Focoos AI helps you save both time and money while delivering high-performance AI models ๐Ÿ’ช: @@ -103,18 +105,6 @@ Using Focoos AI helps you save both time and money while delivering high-perform - **4x Cheaper** ๐Ÿ’ฐ: Our models require up to 4x less computational power, letting you save on hardware or cloud bill while ensuring high-quality results. - **Tons of CO2 saved annually per model** ๐ŸŒฑ: Our models are energy-efficient, helping you reduce your carbon footprint by using less powerful hardware with respect to mainstream models. -These are not empty promises, but the result of years of research and development by our team ๐Ÿ”ฌ: -
-
- ADE-20k Semantic Segmentation -
ADE-20k Semantic Segmentation Results
-
-
- COCO Object Detection -
COCO Object Detection Results
-
-
- See the list of our models in the [models](https://focoosai.github.io/focoos/models/) section. --- diff --git a/docs/api/auto_dataset.md b/docs/api/auto_dataset.md new file mode 100644 index 00000000..e3527c69 --- /dev/null +++ b/docs/api/auto_dataset.md @@ -0,0 +1,2 @@ +::: focoos.data.auto_dataset +::: focoos.data.default_aug diff --git a/docs/api/base_model.md b/docs/api/base_model.md new file mode 100644 index 00000000..57819c00 --- /dev/null +++ b/docs/api/base_model.md @@ -0,0 +1 @@ +::: focoos.models.base_model diff --git a/docs/api/focoos.md b/docs/api/focoos.md deleted file mode 100644 index 2166ffcc..00000000 --- a/docs/api/focoos.md +++ /dev/null @@ -1 +0,0 @@ -::: focoos.focoos diff --git a/docs/api/focoos_model.md b/docs/api/focoos_model.md new file mode 100644 index 00000000..903fea2d --- /dev/null +++ b/docs/api/focoos_model.md @@ -0,0 +1 @@ +::: focoos.models.focoos_model diff --git a/docs/api/hub.md b/docs/api/hub.md new file mode 100644 index 00000000..24c7d6f9 --- /dev/null +++ b/docs/api/hub.md @@ -0,0 +1,4 @@ +::: focoos.hub.api_client +::: focoos.hub.focoos_hub +::: focoos.hub.remote_dataset +::: focoos.hub.remote_model diff --git a/docs/api/infer_model.md b/docs/api/infer_model.md new file mode 100644 index 00000000..b75a7d80 --- /dev/null +++ b/docs/api/infer_model.md @@ -0,0 +1 @@ +::: focoos.infer.infer_model diff --git a/docs/api/local_model.md b/docs/api/local_model.md deleted file mode 100644 index 39594226..00000000 --- a/docs/api/local_model.md +++ /dev/null @@ -1 +0,0 @@ -::: focoos.local_model diff --git a/docs/api/model_manager.md b/docs/api/model_manager.md new file mode 100644 index 00000000..193089a8 --- /dev/null +++ b/docs/api/model_manager.md @@ -0,0 +1 @@ +::: focoos.model_manager diff --git a/docs/api/model_registry.md b/docs/api/model_registry.md new file mode 100644 index 00000000..c30c55e3 --- /dev/null +++ b/docs/api/model_registry.md @@ -0,0 +1 @@ +::: focoos.model_registry.model_registry diff --git a/docs/api/processor.md b/docs/api/processor.md new file mode 100644 index 00000000..cb1bccbc --- /dev/null +++ b/docs/api/processor.md @@ -0,0 +1,2 @@ +::: focoos.processor.base_processor +::: focoos.processor.processor_manager diff --git a/docs/api/remote_dataset.md b/docs/api/remote_dataset.md deleted file mode 100644 index bb80573e..00000000 --- a/docs/api/remote_dataset.md +++ /dev/null @@ -1 +0,0 @@ -::: focoos.remote_dataset.RemoteDataset diff --git a/docs/api/remote_model.md b/docs/api/remote_model.md deleted file mode 100644 index 475ab1e8..00000000 --- a/docs/api/remote_model.md +++ /dev/null @@ -1 +0,0 @@ -::: focoos.remote_model diff --git a/docs/api/runtime.md b/docs/api/runtime.md deleted file mode 100644 index e864df37..00000000 --- a/docs/api/runtime.md +++ /dev/null @@ -1,3 +0,0 @@ -# Runtime - -::: focoos.runtime diff --git a/docs/api/runtimes.md b/docs/api/runtimes.md new file mode 100644 index 00000000..17f064cd --- /dev/null +++ b/docs/api/runtimes.md @@ -0,0 +1,4 @@ +::: focoos.infer.runtimes.base +::: focoos.infer.runtimes.load_runtime +::: focoos.infer.runtimes.onnx +::: focoos.infer.runtimes.torchscript diff --git a/docs/api/trainer_evaluation.md b/docs/api/trainer_evaluation.md new file mode 100644 index 00000000..fbb9b906 --- /dev/null +++ b/docs/api/trainer_evaluation.md @@ -0,0 +1,2 @@ +::: focoos.trainer.evaluation.classification_evaluation +::: focoos.trainer.evaluation.get_eval diff --git a/docs/api/trainer_hooks.md b/docs/api/trainer_hooks.md new file mode 100644 index 00000000..c74b746c --- /dev/null +++ b/docs/api/trainer_hooks.md @@ -0,0 +1,2 @@ +::: focoos.trainer.hooks.early_stop +::: focoos.trainer.hooks.sync_to_hub diff --git a/docs/concepts.md b/docs/concepts.md new file mode 100644 index 00000000..847e3407 --- /dev/null +++ b/docs/concepts.md @@ -0,0 +1,304 @@ +# Main Concepts + +## FocoosModel + +The `FocoosModel` class is the main interface for working with computer vision models in Focoos. It provides high-level methods for training, testing, inference, and model export while handling preprocessing and postprocessing automatically. + +### Key Features + +- **End-to-End Inference**: Automatic preprocessing and postprocessing +- **Training Support**: Built-in training pipeline with distributed training support +- **Model Export**: Export to ONNX and TorchScript formats +- **Performance Benchmarking**: Built-in latency and throughput measurement +- **Hub Integration**: Seamless integration with Focoos Hub for model sharing +- **Multiple Input Formats**: Support for PIL Images, NumPy arrays, and PyTorch tensors + +### Loading Strategies + +The primary method for loading models is using the `ModelManager.get()` (see [`ModelManager`](../api/model_manager/#focoos.model_manager.ModelManager)). It supports multiple loading strategies based on the input parameters. The return value is a [Focoos Model](#focoosmodel). + +The ModelManager employs different loading strategies based on the input: + +#### 1. From Focoos Hub + +The Focoos Hub is a cloud-based model repository where you can store, share, and collaborate on models. This method enables seamless model downloading and caching from the hub using the `hub://` protocol. + +**When to use**: Load models shared by other users, access your own cloud-stored models, or work with models that require authentication. + +**Requirements**: Valid API key for private models, internet connection for initial download. + +```python +# Loading from hub using hub:// protocol +# The model is automatically downloaded and cached locally +hub = FocoosHUB(api_key="your_api_key") +model = ModelManager.get("hub://model_reference", hub=hub) + +# Loading with custom configuration override +model = ModelManager.get( + "hub://model_reference", + hub=hub, + cache=True, # Cache for faster subsequent loads + config_parameter=your_value # Override single config parameter +) +``` + +#### 2. From Model Registry + +The Model Registry contains curated, pretrained models that are immediately available without download. These models are optimized, tested, and ready for production use across various computer vision tasks. + +**When to use**: Start with proven, high-quality pretrained models, baseline experiments, or when you need reliable performance without customization. + +**Requirements**: No internet connection needed, models are bundled with the library. + +```python +# Loading pretrained models from registry +# Object detection model trained on COCO dataset +model = ModelManager.get("fai-detr-l-coco") + +# Semantic segmentation model for ADE20K dataset +model = ModelManager.get("fai-mf-l-ade") + +# Check available models first +from focoos import ModelRegistry +available_models = ModelRegistry.list_models() +print("Available models:", available_models) + +# Get detailed information before loading +model_info = ModelRegistry.get_model_info("fai-detr-l-coco") +print(f"Classes: {len(model_info.classes)}, Task: {model_info.task}") +``` + +**Available Model Categories**: + + - **Object Detection**: `fai-detr-l-coco`, `fai-detr-m-coco`, `fai-detr-l-obj365` + - **Instance Segmentation**: `fai-mf-l-coco-ins`, `fai-mf-m-coco-ins`, `fai-mf-s-coco-ins` + - **Semantic Segmentation**: `fai-mf-l-ade`, `fai-mf-m-ade`, `bisenetformer-l-ade`, `bisenetformer-m-ade`, `bisenetformer-s-ade` + +#### 3. From Local Directory + +Load models from your local filesystem, whether they're custom-trained models or models stored in non-standard locations. This method provides maximum flexibility for local development and deployment scenarios. + +**When to use**: Load custom-trained models, work with locally stored models, integrate with existing model storage systems, or work in offline environments. + +**Requirements**: Valid model directory containing model artifacts (weights, configuration, metadata). + +```python +# Loading with custom models directory +model = ModelManager.get("my_model", models_dir="/custom/models/dir") + +# Expected directory structure: +# /path/to/local/model/ +# โ”œโ”€โ”€ model_info.json # Model metadata and configuration +# โ”œโ”€โ”€ model_final.pth # Model weights (optional) + +# Loading with configuration override +model = ModelManager.get( + "local_model", + models_dir="/custom/models/dir", + arg1=value1, + arg2=value2, +) +``` + +#### 4. From ModelInfo Object + +The [`ModelInfo`](../api/ports/#focoos.ports.ModelInfo) class represents comprehensive model metadata including architecture specifications, training configuration, class definitions, and performance metrics. This method provides the most programmatic control over model instantiation. + +**When to use**: Programmatically construct models, work with dynamic configurations, integrate with custom model management systems, or when you need fine-grained control over model instantiation. + +**Requirements**: Properly constructed ModelInfo object with valid configuration parameters. + +```python +# Loading from JSON file +model_info = ModelInfo.from_json("path/to/model_info.json") +model = ModelManager.get("any_name", model_info=model_info) + +# Programmatically creating ModelInfo +from focoos.ports import ModelInfo, ModelFamily, Task + +model_info = ModelInfo( + name="custom_detector", + model_family=ModelFamily.DETR, + classes=["person", "car", "bicycle"], + im_size=640, + task=Task.DETECTION, + config={ + "num_classes": 3, + "backbone_config": {"depth": 50, "model_type": "resnet"}, + "threshold": 0.5 + }, + weights_uri="path/to/weights.pth", # Optional + description="Custom object detector" +) + +model = ModelManager.get("custom_detector", model_info=model_info) +``` + +### Predict + +Performs end-to-end inference on input images with automatic preprocessing and postprocessing. The model accepts input images in various formats including: + +- PIL Image objects (`PIL.Image.Image`) +- NumPy arrays (`numpy.ndarray`) +- PyTorch tensors (`torch.Tensor`) + +The input images are automatically preprocessed to the correct size and format required by the model. After inference, the raw model outputs are postprocessed into a standardized [`FocoosDetections`](../api/ports/#focoos.ports.FocoosDetections) format that provides easy access to: + +- Detected object classes and confidence scores +- Bounding box coordinates +- Segmentation masks (for segmentation models) +- Additional model-specific outputs + +This provides a simple, unified interface for running inference regardless of the underlying model architecture or task. + +**Parameters:** +- `inputs`: Input images in various supported formats (`PIL.Image.Image`, `numpy.ndarray`, `torch.Tensor`) +- `**kwargs`: Additional arguments passed to postprocessing + +**Returns:** [`FocoosDetections`](../api/ports/#focoos.ports.FocoosDetections) containing detection/segmentation results + +**Example:** +```python +from PIL import Image + +# Load an image +image = Image.open("example.jpg") + +# Run inference +detections = model(image) + +# Access results +for detection in detections.detections: + print(f"Class: {detection.label}, Confidence: {detection.conf}") + print(f"Bounding box: {detection.bbox}") +``` + +### Training +Trains the model on provided datasets. The training function accepts: + +- `args`: Training configuration ([TrainerArgs](../api/ports/#focoos.ports.TrainerArgs)) specifying the main hyperparameters, among which: + - `run_name`: Name for the training run + - `output_dir`: Name for the output folder + - `num_gpus`: Number of GPUs to use (must be >= 1) + - `sync_to_hub`: For tracking the experiment on the Focoos Hub. + -`batch_size`, `learning_rate`, `max_iters` and other hyperparameters +- `data_train`: Training dataset (MapDataset) +- `data_val`: Validation dataset (MapDataset) +- `hub`: Optional FocoosHUB instance for experiment tracking + +The data can be obtained using the [AutoDataset](../api/auto_dataset/#focoos.data.auto_dataset.AutoDataset) helper. + +After the training is complete, the model will have updated weights and can be used for inference or export. Furthermore, in the `output_dir` can be found the model metadata (`model_info.json`) and the PyTorch weights (`model_final.pth`). + +**Example:** +```python +from focoos import TrainerArgs +from focoos.data import MapDataset + +# Configure training +train_args = TrainerArgs( + run_name="my_custom_model", + max_iters=5000, + batch_size=16, + learning_rate=1e-4, + num_gpus=2, + sync_to_hub=True, +) + +# Train the model +model.train(train_args, train_dataset, val_dataset, hub=hub) +``` + +[Here](training.md) you can find an extensive training tutorial. + +### Model Export + +Exports the model to different runtime formats for optimized inference. The main function arguments are: + - `runtime_type`: specify the target runtime and must be one of the supported (see [RuntimeType](../api/ports/#focoos.ports.RuntimeType)) + - `out_dir`: the destination folder for the exported model + - `image_size`: the target image size, as an optional integer + +The function returns an [`InferModel`](#infer-model) instance for the exported model. + +**Example:** +```python +# Export to ONNX with TensorRT optimization +infer_model = model.export( + runtime_type=RuntimeType.ONNX_TRT16, + out_dir="./exported_models", + overwrite=True +) + +# Use exported model for fast inference +fast_detections = infer_model(image) +``` +--- + +## Infer Model +The `InferModel` class represents an optimized model for inference, typically created through the export process of a `FocoosModel`. It provides a streamlined interface focused on fast and efficient inference while maintaining the same input/output format as the original model. + +### Key Features + +- **Optimized Performance**: Models are optimized for the target runtime (e.g., TensorRT, ONNX) +- **Consistent Interface**: Uses the same input/output format as FocoosModel +- **Resource Management**: Proper cleanup of runtime resources when no longer needed +- **Multiple Input Formats**: Support for PIL Images, NumPy arrays, and PyTorch tensors + +### Initialization + +InferModel instances are typically created through the `export()` method of a [FocoosModel](#focoosmodel), which handles the model optimization and conversion process. This method allows you to specify the target runtime (see the availables in [`Runtimetypes`](focoos/api/ports/#focoos.ports.RuntimeType)) and the output directory for the exported model. The `export()` method returns an `InferModel` instance that is optimized for fast and efficient inference. + +**Example:** +```python +# Export the model to ONNX format +infer_model = model.export( + runtime_type=RuntimeType.TORCHSCRIPT_32, + out_dir="./exported_models" +) + +# Use the exported model for inference +results = infer_model(input_image) +``` + +### Predict + +Performs end-to-end inference on input images with automatic preprocessing and postprocessing on the selected runtime. The model accepts input images in various formats including: + +- PIL Image objects (`PIL.Image.Image`) +- NumPy arrays (`numpy.ndarray`) +- PyTorch tensors (`torch.Tensor`) + +The input images are automatically preprocessed to the correct size and format required by the model. After inference, the raw model outputs are postprocessed into a standardized [`FocoosDetections`](./api/ports/#focoos.ports.FocoosDetections) format that provides easy access to: + +- Detected object classes and confidence scores +- Bounding box coordinates +- Segmentation masks (for segmentation models) +- Additional model-specific outputs + +This provides a simple, unified interface for running inference regardless of the underlying model architecture or task. + +**Parameters:** +- `inputs`: Input images in various supported formats (`PIL.Image.Image`, `numpy.ndarray`, `torch.Tensor`) +- `**kwargs`: Additional arguments passed to postprocessing + +**Returns:** [`FocoosDetections`](../api/ports/#focoos.ports.FocoosDetections) containing detection/segmentation results + +**Example:** +```python +from PIL import Image + +# Load an image +image = Image.open("example.jpg") + +# Run inference +infer_model = model.export( + runtime_type=RuntimeType.TORCHSCRIPT_32, + out_dir="./exported_models" +) +detections = infer_model(image) + +# Access results +for detection in detections.detections: + print(f"Class: {detection.label}, Confidence: {detection.conf}") + print(f"Bounding box: {detection.bbox}") +``` diff --git a/docs/howto/create_dataset.md b/docs/howto/create_dataset.md deleted file mode 100644 index d4814d98..00000000 --- a/docs/howto/create_dataset.md +++ /dev/null @@ -1,192 +0,0 @@ -# Dataset Management - -This section covers the steps to create, upload, and manage datasets in Focoos using the SDK. -The `focoos` library supports multiple dataset formats, making it flexible for various machine learning tasks. - -[![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/FocoosAI/focoos/blob/main/notebooks/dataset.ipynb) - - -In this guide, we will show the following steps: - -1. [๐Ÿงฌ Dataset format](#1-dataset-format) -2. [๐Ÿ“ธ Create dataset](#2-create-dataset) -3. [๐Ÿ“ค Upload data](#3-upload-data) -4. [๐Ÿ“ฅ Download your own dataset from Focoos](#4-download-your-own-dataset-from-focoos-platform) -5. [๐ŸŒ Download dataset from external sources](#5-download-dataset-from-external-sources) -6. [๐Ÿ—‘๏ธ Delete data](#6-delete-data) -7. [๐Ÿšฎ Delete dataset](#7-delete-dataset) - - -## 1. Dataset format -The `focoos` library currently supports three distinct dataset layouts, providing seamless compatibility with various machine learning workflows. Below are the supported formats along with their respective folder structures: - -- **ROBOFLOW_COCO** (Detection, Instance Segmentation): -```python -root/ - train/ - - _annotations.coco.json - - img_1.jpg - - img_2.jpg - valid/ - - _annotations.coco.json - - img_3.jpg - - img_4.jpg -``` -- **ROBOFLOW_SEG** (Semantic Segmentation): -```python -root/ - train/ - - _classes.csv (comma separated csv) - - img_1.jpg - - img_2.jpg - valid/ - - _classes.csv (comma separated csv) - - img_3_mask.png - - img_4_mask.png -``` -- **SUPERVISELY** (Semantic Segmentation): -```python -root/ - train/ - meta.json - img/ - ann/ - mask/ - valid/ - meta.json - img/ - ann/ - mask/ -``` - -!!! Note - More dataset formats will be added soon. If you need support for a specific format, feel free to reach out via email at [support@focoos.ai](mailto:support@focoos.ai) - - -## 2. Create dataset -The `focoos` library enables you to create datasets tailored for specific deep learning tasks, such as object detection and semantic segmentation. The available computer vision tasks are defined in the [FocoosTask function](../api/ports.md/#focoos.ports.FocoosTask). Each dataset must follow a specific structure to ensure compatibility with the Focoos platform. You can select the appropriate dataset format from the supported options detailed in [Dataset Format](#1-dataset-format). - -Use the following code to create a new dataset: - -```python -from focoos import DatasetLayout, Focoos, FocoosTask - -focoos = Focoos(api_key="") - -# Create a new remote dataset -dataset = focoos.add_remote_dataset( - name="my-dataset", - description="My custom dataset for object detection", - layout=DatasetLayout.ROBOFLOW_COCO, # Choose dataset format - task=FocoosTask.DETECTION # Specify the task type -) -``` - - -## 3. Upload data -Once you've created a dataset, you can upload your data as a ZIP archive from your local folder: - -```python -dataset.upload_data("./datasets/my_dataset.zip") -``` - -After the upload, you can check dataset [preview](../api/ports.md/#focoos.ports.DatasetPreview) using: - -```python -dataset_info = dataset.get_info() -print(dataset_info) -``` - -Alternatively, you can list all available datasets (both personal and shared): - -```python -datasets = focoos.list_datasets() -for dataset in datasets: - print(f"Name: {dataset.name}") - print(f"Reference: {dataset.ref}") - print(f"Task: {dataset.task}") - print(f"Description: {dataset.description}") - print(f"spec: {dataset.spec}") - print("-" * 50) -``` - - -## 4. Download your own dataset from Focoos platform -If you have previously uploaded a dataset to Focoos platform, you can retrieve it by following these steps. -First, list all your datasets to identify the dataset reference: - - -```python -datasets = focoos.list_datasets() - -for dataset in datasets: - print(f"Name: {dataset.name}") - print(f"Reference: {dataset.ref}") -``` - -Once you have the dataset reference, use the following code to download the associated data to a predefined local folder: - -```python -dataset_ref = "" -dataset = focoos.get_remote_dataset(dataset_ref) - -dataset.download_data(".//") -``` - - - -## 5. Download dataset from external sources -You can also download datasets from external sources like Dataset-Ninja (Supervisely) and Roboflow Universe, then upload them to the Focoos platform for use in your projects. - -=== "pip" - ```bash linenums="0" - pip install dataset-tools roboflow - pip install setuptools - ``` - -- **Dataset Ninja**: -```python -import dataset_tools as dtools - -dtools.download(dataset="dacl10k", dst_dir="./datasets/dataset-ninja/") -``` - -- **Roboflow**: -```python -import os - -from roboflow import Roboflow - -rf = Roboflow(api_key=os.getenv("ROBOFLOW_API_KEY")) -project = rf.workspace("roboflow-58fyf").project("rock-paper-scissors-sxsw") -version = project.version(14) -dataset = version.download("coco") -``` - - -## 6. Delete data -If you need to remove specific files from an existing dataset without deleting the entire dataset, you can do so by specifying the filename. This is useful when updating or refining your dataset. - -Use the following command: -```python -dataset_ref = "" -dataset = focoos.get_remote_dataset(dataset_ref) -dataset.delete_data() -``` -!!! warning - This will permanently remove the specified file from your dataset in Focoos platform. Be sure to double-check the filename before executing the command, as deleted data cannot be recovered. - - - -## 7. Delete dataset -If you want to remove an entire dataset from the Focoos platform, use the following command: - -```python -dataset_ref = "" -dataset = focoos.get_remote_dataset(dataset_ref) -dataset.delete() -``` -!!! warning - Deleting a dataset is irreversible. Once deleted, all data associated with the dataset is permanently lost and cannot be recovered. - -## diff --git a/docs/howto/manage_models.md b/docs/howto/manage_models.md deleted file mode 100644 index ccc0fe24..00000000 --- a/docs/howto/manage_models.md +++ /dev/null @@ -1,119 +0,0 @@ -# Model Management - -This section covers the steps to monitor the status of your models on the FocoosAI platform. - -[![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/FocoosAI/focoos/blob/main/notebooks/model_management.ipynb) - - -In this guide, we will cover the following topics: - -1. [๐Ÿ“‹ List available Focoos models](#how-to-list-the-focoos-models) -2. [๐Ÿ“œ List all your models](#how-to-list-all-your-models) -3. [๐Ÿ“ˆ Retrieve model metrics](#see-the-metrics-for-a-model) -4. [๐Ÿ—‘๏ธ Delete a model](#delete-a-model) - - - - -## How to list the Focoos models -To list all the models available on the Focoos AI platform, you can use the following code: -```python -from focoos import Focoos - -focoos = Focoos(api_key="") - -models = focoos.list_focoos_models() -for model in models: - print(f"Name: {model.name}") - print(f"Reference: {model.ref}") - print(f"Status: {model.status}") - print(f"Task: {model.task}") - print(f"Description: {model.description}") - print("-" * 50) - - -``` -`models` is a list of [`ModelPreview`](/focoos/api/ports/#focoos.ports.ModelPreview) objects that contains the following information: - -- `name`: The name of the model. -- `ref`: The reference of the model. -- `status`: The status of the model. -- `task`: The task of the model. -- `description`: The description of the model. -- `status`: The status of the model, which indicates its current state (e.g. CREATED, TRAINING_RUNNING, TRAINING_COMPLETED - see [`ModelStatus`](/focoos/api/ports/#focoos.ports.ModelStatus)). - - -## How to list all your models -To list all your models, the library provides a `list_models` function. This function will return a list of `Model` objects. - -```python -from focoos import Focoos - -focoos = Focoos(api_key="") - -models = focoos.list_models() -for model in models: - print(f"Name: {model.name}") - print(f"Reference: {model.ref}") - print(f"Status: {model.status}") - print(f"Task: {model.task}") - print(f"Description: {model.description}") - print(f"Focoos Model: {model.focoos_model}") - print("-" * 50) - -``` -`models` is a list of [`ModelPreview`](/focoos/api/ports/#focoos.ports.ModelPreview) objects that contains the following information: - -- `name`: The name of the model. -- `ref`: The reference of the model. -- `status`: The status of the model. -- `task`: The task of the model. -- `description`: The description of the model. -- `focoos_model`: The starting Focoos Model used for training. -- `status`: The status of the model, which indicates its current state (e.g. CREATED, TRAINING_RUNNING, TRAINING_COMPLETED - see [`ModelStatus`](/focoos/api/ports/#focoos.ports.ModelStatus)). - - -## See the metrics for a model -To see the validation metrics of a model, you can use the [`metrics` method](/focoos/api/remote_model/#focoos.remote_model.RemoteModel.metrics) on the model object. - -```python -from focoos import Focoos - -focoos = Focoos(api_key="") - -model = focoos.get_remote_model("my-model") -metrics = model.metrics() - -if metrics.best_valid_metric: - print(f"Best validation metrics:") - for k, v in metrics.best_valid_metric.items(): - print(f" {k}: {v}") - -if metrics.valid_metrics: - print(f"Last iteration validation metrics:") - for k, v in metrics.valid_metrics[-1].items(): - print(f" {k}: {v}") - -if metrics.train_metrics: - print(f"Last iteration training metrics:") - for k, v in metrics.train_metrics[-1].items(): - print(f" {k}: {v}") - -``` -`metrics` is a [`Metrics`](/focoos/api/ports/#focoos.ports.Metrics) object that contains the validation metrics of the model. - -## Delete a model -To delete a model, you can use the [`delete_model` method](/focoos/api/remote_model/#focoos.remote_model.RemoteModel.delete_model) on the model object. - -```python -from focoos import Focoos - -focoos = Focoos(api_key="") - -model = focoos.get_remote_model("my-model") -model.delete_model() -``` - -!!! warning - This action is irreversible. - Ensure you double-check before executing this command, as once deleted, the model cannot be recovered. diff --git a/docs/howto/manage_user.md b/docs/howto/manage_user.md deleted file mode 100644 index 169ffc08..00000000 --- a/docs/howto/manage_user.md +++ /dev/null @@ -1,67 +0,0 @@ -# User Management - -Managing your user information is essential for tracking account details, platform usage, and resource quotas. -The Focoos library provides built-in methods to retrieve your user information, including email, API key details, company affiliation, and allocated usage quotas. - -[![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/FocoosAI/focoos/blob/main/notebooks/user_info.ipynb) - -In this guide, we will cover the following topics: - -1. [๐Ÿ“„ Retrieve User Information](#retrieve-user-information) -2. [๐Ÿ“Š Monitor Quota Usage](#monitor-your-quota-usage) - - -## Retrieve user information -To access your user details, you can use the `get_user_info` function provided by the Focoos library. This function returns a `User` object containing key account information. - -```python -from focoos import Focoos - -focoos = Focoos(api_key="") - -user_info = focoos.get_user_info() - -print(f"Email: {user_info.email}") -print(f"Created at: {user_info.created_at}") -print(f"Updated at: {user_info.updated_at}") -if user_info.company: - print(f"Company: {user_info.company}") - -``` -The `user_info` object contains the following fields: - -- `email`: The email address associated with your account -- `created_at`: Timestamp of when the account was created -- `updated_at`: Timestamp of the last account update -- `company`: The company affiliated with your account (if applicable) -- `api_key`: Your API key details used for authentication -- `quotas`: Your allocated platform usage quotas (see [`Quotas`](/focoos/api/ports/#focoos.ports.Quotas) allocated to the user). - - -## Monitor your quota usage - -The Focoos platform enforces usage quotas to manage resources efficiently. -You can retrieve your current quota limits using the `get_user_info` function like this: - - -```python -from focoos import Focoos - -focoos = Focoos(api_key="") - -user_info = focoos.get_user_info() - -print("\nQuotas:") -print(f"Total inferences: {user_info.quotas.total_inferences}") -print(f"Max inferences: {user_info.quotas.max_inferences}") -print(f"Used storage (GB): {user_info.quotas.used_storage_gb}") -print(f"Max storage (GB): {user_info.quotas.max_storage_gb}") -print(f"Active training jobs: {user_info.quotas.active_training_jobs}") -print(f"Max active training jobs: {user_info.quotas.max_active_training_jobs}") -print(f"Used training jobs hours: {user_info.quotas.used_mlg4dnxlarge_training_jobs_hours}") -print(f"Max training jobs hours: {user_info.quotas.max_mlg4dnxlarge_training_jobs_hours}") - -``` - -!!! note - If you need to increase your quotas, please contact us at [support](mailto:support@focoos.ai). diff --git a/docs/howto/personalize_model.md b/docs/howto/personalize_model.md deleted file mode 100644 index de33eba7..00000000 --- a/docs/howto/personalize_model.md +++ /dev/null @@ -1,179 +0,0 @@ -# Create and Train Model - -This section covers the steps to create a model and train it in the cloud using the `focoos` library. The following example demonstrates how to interact with the Focoos API to manage models, datasets, and training jobs. - -[![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/FocoosAI/focoos/blob/main/notebooks/training.ipynb) - -In this guide, we will perform the following steps: - -1. [๐Ÿ“ฆ Select dataset](#1-select-dataset) -2. [๐ŸŽฏ Create model](#2-create-model) -3. [๐Ÿƒโ€โ™‚๏ธ Train model](#3-train-model) -4. [๐Ÿ“Š Visualize training metrics](#4-visualize-training-metrics) -5. [๐Ÿงช Test model](#5-test-model) - - ---- - -## 1. Select dataset - -You can list publicly shared datasets using the following code: - -```python -from focoos import Focoos - -focoos = Focoos(api_key="") - -datasets = focoos.list_shared_datasets() -for dataset in datasets: - print(f"Name: {dataset.name}") - print(f"Reference: {dataset.ref}") - print(f"Task: {dataset.task}") - print(f"Description: {dataset.description}") - print("-" * 50) -``` - -To view only your personal datasets, use the following code: -```python -from focoos import Focoos - -focoos = Focoos(api_key="") - -datasets = focoos.list_datasets(include_shared=False) -for dataset in datasets: - print(f"Name: {dataset.name}") - print(f"Reference: {dataset.ref}") - print(f"Task: {dataset.task}") - print(f"Description: {dataset.description}") - print("-" * 50) -``` - -!!! Note - If you havenโ€™t uploaded a dataset yet, you can follow this guide: [How to load a dataset](./create_dataset.md) - - -Once you've identified the dataset you want to use, youโ€™ll need its reference `dataset_ref` to train your model. You can either copy it or store it in a variable like this: -```python -dataset_ref = "" -``` - - -## 2. Create model -The first step to personalize your model is to create a model. -You can create a model by calling the [`new_model` method](/focoos/api/focoos/#focoos.focoos.Focoos.new_model) on the `Focoos` object. You can choose the model you want to personalize from the list of [Focoos Models](../models.md) available on the platform. Make sure to select the correct model for your task. - -```python -from focoos import Focoos - -focoos = Focoos(api_key="") - -model = focoos.new_model( - name="", - description="", - focoos_model="", -) -``` -An example of how to create a model is the following: -```python -model = focoos.new_model( - name="my-model", - description="my-model-description", - focoos_model="fai-rtdetr-m-obj365", -) -``` -This function will return a new [`RemoteModel`](/focoos/api/remote_model/#focoos.remote_model.RemoteModel) object that you can use to train the model and to perform remote inference. - -## 3. Train model -Once the model is created, you can start the training process by calling the [`train` method](/focoos/api/remote_model/#focoos.remote_model.RemoteModel.train) on the model object. - -```python -from focoos.ports import Hyperparameters - -res = model.train( - dataset_ref=dataset_ref, - hyperparameters=Hyperparameters( - learning_rate=0.0001, # custom learning rate - batch_size=16, # custom batch size - max_iters=1500, # custom max iterations - ), -) -``` -For selecting the `dataset_ref` see the [step 2](create_dataset.md/#2-create-dataset). -You can further customize the training process by passing additional parameters to the [`train` method](/focoos/api/remote_model/#focoos.remote_model.RemoteModel.train) (such as the instance type, the volume size, the maximum runtime, etc.) or use additional hyperparameters (see the list [available hyperparameters](/focoos/api/ports/#focoos.ports.Hyperparameters)). - -Futhermore, you can monitor the training progress by polling the training status. Use the [`notebook_monitor_train`](/focoos/api/remote_model/#focoos.remote_model.RemoteModel.notebook_monitor_train) method on a jupyter notebook: -```python -model.notebook_monitor_train(interval=30, plot_metrics=True) -``` - -You can also get the training logs by calling the [`train_logs` method](/focoos/api/remote_model/#focoos.remote_model.RemoteModel.train_logs): -```python -logs = model.train_logs() -pprint(logs) -``` - -Finally, if for some reason you need to cancel the training, you can do so by calling the [`stop_training` method](/focoos/api/remote_model/#focoos.remote_model.RemoteModel.stop_training): -```python -model.stop_training() -``` - -## 4. Visualize training metrics -You can visualize the training metrics by calling the [`metrics` method](/focoos/api/remote_model/#focoos.remote_model.RemoteModel.metrics): -```python -metrics = model.metrics() -visualizer = MetricsVisualizer(metrics) -visualizer.log_metrics() -``` -The function will return an object of type [`Metrics`](/focoos/api/ports/#focoos.ports.Metrics) that you can use to visualize the training metrics using a `MetricsVisualizer` object. - -On notebooks, you can also plot the metrics by calling the [`notebook_plot_training_metrics`](/focoos/api/remote_model/#focoos.remote_model.RemoteModel.notebook_plot_training_metrics) method: -```python -visualizer.notebook_plot_training_metrics() -``` - -## 5. Test model - -### Remote inference -Once the training is over, you can test your model using remote inference by calling the [`infer` method](/focoos/api/remote_model/#focoos.remote_model.RemoteModel.infer) on the model object. - -```python -image_path = "" -result, _ = model.infer(image_path, threshold=0.5, annotate=False) - -for det in result.detections: - print(f"Found {det.label} with confidence {det.conf:.2f}") - print(f"Bounding box: {det.bbox}") - if det.mask: - print("Instance segmentation mask included") -``` -`result` is a [FocoosDetections](/focoos/api/ports/#focoos.ports.FocoosDetections) object, containing a list of [FocoosDet](/focoos/api/ports/#focoos.ports.FocoosDet) objects and optionally a dict of information about the latency of the inference. - -The `threshold` parameter is optional and defines the minimum confidence score for a detection to be considered valid (predictions with a confidence score lower than the threshold are discarded). - -Optionally, you can preview the results by passing the `annotate` parameter to the `infer` method. -```python -from PIL import Image - -output, preview = model.infer(image_path, threshold=0.5, annotate=True) -preview = Image.fromarray(preview[:,:,[2,1,0]]) # invert to make it RGB -``` - -### Local inference -!!! Note - To perform local inference, you need to install the package with one of the extra modules (`[cpu]`, `[torch]`, `[cuda]`, `[tensorrt]`). See the [installation](../setup.md) page for more details. - -You can perform inference locally by getting the [`LocalModel`](/focoos/api/local_model) you already trained and calling the [`infer` method](/focoos/api/local_model/#focoos.local_model.LocalModel.infer) on your image. If it's the first time you run the model locally, the model will be downloaded from the cloud and saved on your machine. Additionally, if you use CUDA or TensorRT, the model will be optimized for your GPU before running the inference (it can take few seconds, especially for TensorRT). - -```python -model = focoos.get_local_model(model.model_ref) # get the local model - -image_path = "" -result, _ = model.infer(image_path, threshold=0.5, annotate=False) - -for det in result.detections: - print(f"Found {det.label} with confidence {det.conf:.2f}") - print(f"Bounding box: {det.bbox}") - if det.mask: - print("Instance segmentation mask included") -``` -As for remote inference, you can pass the `annotate` parameter to return a preview of the prediction and play with the `threshold` parameter to change the minimum confidence score for a detection to be considered valid. diff --git a/docs/howto/use_model.md b/docs/howto/use_model.md deleted file mode 100644 index fe4534e5..00000000 --- a/docs/howto/use_model.md +++ /dev/null @@ -1,191 +0,0 @@ -# Select and Inference with Focoos Models - -This section covers how to perform inference using the [Focoos Models](../models.md) on the cloud or locally using the `focoos` library. - -As a reference, the following example demonstrates how to perform inference using the [`fai-rtdetr-m-obj365`](../models/fai-rtdetr-m-obj365.md) model, but you can use any of the models listed in the [models](../models.md) section. - -[![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/FocoosAI/focoos/blob/main/notebooks/inference.ipynb) - -In this guide, we will cover the following topics: - -1. [โ˜๏ธ Cloud Inference](#cloud-inference-for-image-processing) -! -2. [โ˜๏ธ Cloud Inference with Gradio](#cloud-inference-with-gradio) -3. [๐Ÿ  Local Inference](#local-inference) - - -## Cloud inference for image processing -Running inference on the cloud is simple and efficient. Select the model you want to use and call the [`infer` method](/focoos/api/remote_model/#focoos.remote_model.RemoteModel.infer) on your image. The image will be securely uploaded to the Focoos AI platform, where the selected model processes it and returns the results. - -To get the model reference you can refere to the [Model Management section](manage_models.md). Here the code to handle a single image inference: - -```python -from focoos import Focoos - -focoos = Focoos(api_key="") - -image_path = "" -model = focoos.get_remote_model("") -result, _ = model.infer(image_path, threshold=0.5, annotate=True) - -for det in result.detections: - print(f"Found {det.label} with confidence {det.conf:.2f}") - print(f"Bounding box: {det.bbox}") - if det.mask: - print("Instance segmentation mask included") - print(f"Mask shape: {det.mask.shape}") -``` - -`result` is a [FocoosDetections](/focoos/api/ports/#focoos.ports.FocoosDetections) object, containing a list of [FocoosDet](/focoos/api/ports/#focoos.ports.FocoosDet) objects and optionally a dict of information about the latency of the inference. The `FocoosDet` object contains the following attributes: - -- `bbox`: Bounding box coordinates in x1y1x2y2 absolute format. -- `conf`: Confidence score (from 0 to 1). -- `cls_id`: Class ID (0-indexed). -- `label`: Label (name of the class). -- `mask`: Mask (base64 encoded string having origin in the top left corner of bbox and the same width and height of the bbox). - - -Optional parameters are: - -- `threshold` (default: 0.5) โ€“ Sets the minimum confidence score required for prediction to be considered valid. Predictions below this threshold are discarded. -- `annotate` (default: False) โ€“ If set to True, the method returns preview, an annotated image with the detected objects. - - -### Image Preview - -You can preview the results by passing the `annotate` parameter to the `infer` method: - -```python -from PIL import Image - -output, preview = model.infer(image_path, threshold=0.5, annotate=True) -preview = Image.fromarray(preview) - -``` - - - -## Cloud inference with Gradio -You can easily create a web interface for your model using Gradio. - -First, install the required `dev` dependencies: - -```bash linenums="0" -pip install '.[dev]' -``` - -Set your Focoos API key as an environment variable and start the application. The model selection will be available from the UI: - -```bash linenums="0" -export FOCOOS_API_KEY_GRADIO= && python gradio/app.py -``` -Now, your model is accessible through a user-friendly web interface! ๐Ÿš€ - - -## Local inference -!!! Note - To perform local inference, you need to install the package with one of the extra modules (`[cpu]`, `[torch]`, `[cuda]`, `[tensorrt]`). See the [installation](../setup.md) page for more details. - -You can run inference locally by selecting a model and calling the [`infer` method](/focoos/api/local_model/#focoos.local_model.LocalModel.infer) on your image. -If this is the first time you are running the model locally, it will be downloaded from the cloud and stored on your machine. -If you are using CUDA or TensorRT, the model will be optimized for your GPU before inference. This process may take a few seconds, especially for TensorRT. - -Example code: -```python -from focoos import Focoos - -focoos = Focoos(api_key="") - -model = focoos.get_local_model("") - -image_path = "" -result, _ = model.infer(image_path, threshold=0.5, annotate=True) - -for det in result.detections: - print(f"Found {det.label} with confidence {det.conf:.2f}") - print(f"Bounding box: {det.bbox}") - if det.mask: - print("Instance segmentation mask included") - print(f"Mask shape: {det.mask.shape}") - -``` - -`result` is a [FocoosDetections](/focoos/api/ports/#focoos.ports.FocoosDetections) object, containing a list of [FocoosDet](/focoos/api/ports/#focoos.ports.FocoosDet) objects and optionally a dict of information about the latency of the inference. The `FocoosDet` object contains the following attributes: - -- `bbox`: Bounding box coordinates in x1y1x2y2 absolute format. -- `conf`: Confidence score (from 0 to 1). -- `cls_id`: Class ID (0-indexed). -- `label`: Label (name of the class). -- `mask`: Mask (base64 encoded string having origin in the top left corner of bbox and the same width and height of the bbox). - - - -As for remote inference, you can use the `annotate` parameter to return a preview of the prediction. -Local inference provides faster results and reduces cloud dependency, making it ideal for real-time applications and edge deployments. diff --git a/docs/hub/hub.md b/docs/hub/hub.md new file mode 100644 index 00000000..1d1f1d21 --- /dev/null +++ b/docs/hub/hub.md @@ -0,0 +1,224 @@ +# ๐Ÿš€ Focoos HUB + +The FocoosHUB class is your main interface for interacting with the Focoos cloud platform. It provides comprehensive functionality for managing models, datasets, and performing cloud operations. + +## Getting Started + +### Authentication + +Before using the HUB, you need to authenticate with your API key: + +```python +from focoos import FocoosHUB + +# Option 1: Use API key from configuration +hub = FocoosHUB() + +# Option 2: Explicitly provide API key +hub = FocoosHUB(api_key="your-api-key-here") +``` + +### Get User Information + +Check your account details and quotas: + +```python +user_info = hub.get_user_info() + +print(f"Email: {user_info.email}") +print(f"Company: {user_info.company}") +print(f"Storage used: {user_info.quotas.used_storage_gb}GB") +print(f"Inferences used: {user_info.quotas.total_inferences}") +``` + +## Working with Models + +### List Your Models + +Get an overview of all models in your account: + +```python +models = hub.list_remote_models() + +for model in models: + print(f"Model: {model.name}") + print(f" - Reference: {model.ref}") + print(f" - Task: {model.task}") + print(f" - Status: {model.status}") + print(f" - Created: {model.created_at}") +``` + +### Get Model Information + +Retrieve detailed information about a specific model: + +```python +model_ref = "your-model-reference" +model_info = hub.get_model_info(model_ref) + +print(f"Model Name: {model_info.name}") +print(f"Description: {model_info.description}") +print(f"Task: {model_info.task}") +print(f"Classes: {model_info.classes}") +print(f"Image Size: {model_info.im_size}") +print(f"Status: {model_info.status}") + +# Access training information if available +if model_info.training: + print(f"Training Status: {model_info.training.status}") + print(f"Training Progress: {model_info.training.progress}%") +``` + +### Get Remote Model Instance + +Get a remote model instance for cloud-based operations: + +```python +model_ref = "your-model-reference" +remote_model = hub.get_remote_model(model_ref) + +# This model can be used for remote inference +results = remote_model.infer("path/to/image.jpg", threshold=0.5) + +# Get model training information +training_info = remote_model.train_info() +print(f"Training status: {training_info.status}") + +# Get model metrics +metrics = remote_model.metrics() +print(f"Validation mAP: {metrics.map}") +``` + +## Working with Datasets + +### List Available Datasets + +View datasets you own and optionally shared datasets: + +```python +# List only your datasets +my_datasets = hub.list_remote_datasets() + +# List your datasets and shared datasets +all_datasets = hub.list_remote_datasets(include_shared=True) + +for dataset in all_datasets: + print(f"Dataset: {dataset.name}") + print(f" - Reference: {dataset.ref}") + print(f" - Task: {dataset.task}") + print(f" - Layout: {dataset.layout}") + if dataset.spec: + print(f" - spec.train_length: {dataset.spec.train_length}") + print(f" - spec.valid_length: {dataset.spec.valid_length}") + print(f" - spec.size_mb: {dataset.spec.size_mb}") +``` + +### Working with Remote Datasets + +Get a remote dataset instance and work with it: + +```python +dataset_ref = "your-dataset-reference" +remote_dataset = hub.get_remote_dataset(dataset_ref) + +print(f"Dataset Name: {remote_dataset.name}") +print(f"Task: {remote_dataset.task}") +print(f"Layout: {remote_dataset.layout}") + +# Download dataset data +local_path = remote_dataset.download_data("./datasets") +print(f"Dataset downloaded to: {local_path}") +``` + + +## Error Handling + +The HUB client raises `ValueError` exceptions for API errors: + +```python +try: + model_info = hub.get_model_info("non-existent-model") +except ValueError as e: + print(f"Error retrieving model: {e}") + +try: + models = hub.list_remote_models() +except ValueError as e: + print(f"Error listing models: {e}") +``` + +## Configuration + +The HUB client uses configuration from the global `FOCOOS_CONFIG`: + +```python +from focoos.config import FOCOOS_CONFIG + +# Check current configuration +print(f"API Key: {FOCOOS_CONFIG.focoos_api_key}") +print(f"Log Level: {FOCOOS_CONFIG.focoos_log_level}") +print(f"Runtime type: {FOCOOS_CONFIG.runtime_type}") +print(f"Warmup iter: {FOCOOS_CONFIG.warmup_iter}") + +# The HUB client will use these values by default +hub = FocoosHUB() # Uses FOCOOS_CONFIG values +``` + +## Advanced Usage + +### Model Training Integration + +When training models locally, you can sync them to the HUB: + +```python +from focoos.models.focoos_model import FocoosModel +from focoos.ports import TrainerArgs +from focoos import ModelManager + +# Load a model for training +model = ModelManager.get("fai-detr-l-obj365") + +# Configure training with HUB sync +train_args = TrainerArgs( + max_iters=1000, + batch_size=16, + sync_to_hub=True # This will automatically create a remote model +) + +# Train the model (this will sync to HUB) +model.train(train_args, train_dataset, val_dataset, hub=hub) +``` + +### Monitoring Training + +Monitor training progress of remote models: + +```python +remote_model = hub.get_remote_model("training-model-ref") + +# Get training info +training_info = remote_model.train_info() + +if training_info: + print(f"Algorithm Name: {training_info.algorithm_name}") + print(f"Instance Device: {training_info.instance_device}") + print(f"Instance Type: {training_info.instance_type}") + print(f"Volume Size: {training_info.volume_size}") + print(f"Main Status: {training_info.main_status}") + print(f"Failure Reason: {training_info.failure_reason}") + print(f"Status Transitions: {training_info.status_transitions}") + print(f"Start Time: {training_info.start_time}") + print(f"End Time: {training_info.end_time}") + print(f"Artifact Location: {training_info.artifact_location}") + +# Get training logs +logs = remote_model.train_logs() +for log_entry in logs: + print(log_entry) +``` + +## See Also + +- [Remote Inference](remote_inference.md) - Learn about cloud-based inference +- [Overview](overview.md) - Understand the HUB architecture +- [API Reference](../api/hub.md) - Detailed API documentation diff --git a/docs/hub/overview.md b/docs/hub/overview.md new file mode 100644 index 00000000..23705c2b --- /dev/null +++ b/docs/hub/overview.md @@ -0,0 +1,62 @@ +# ๐Ÿš€ Focoos HUB Overview +The Focoos HUB is a cloud-based platform that provides seamless integration between your local development environment and the Focoos AI ecosystem. It enables you to manage models, datasets, perform remote inference operations, and monitor training progress through a unified API. + +## What is Focoos HUB? + +Focoos HUB serves as your gateway to: + +- **Model Management**: Store, version, and share your trained computer vision models +- **Remote Inference**: Run inference on cloud infrastructure without local GPU requirements +- **Dataset Management**: Upload, download, and manage datasets in the cloud +- **Collaboration**: Share models and datasets with team members +- **Monitoring**: Track model performance and usage metrics + +## Key Components + +### FocoosHUB Client +The main interface for interacting with the Focoos platform. It provides authentication and access to all HUB services. + +```python +from focoos import FocoosHUB + +# Initialize the HUB client +hub = FocoosHUB() + +# Get user information +user_info = hub.get_user_info() +print(f"Welcome {user_info.email}!") +``` + +### Remote Models +Access and manage models stored in the cloud: + +- List your available models +- Get detailed model information +- Download models for local use +- Perform remote inference without downloading + +### Remote Datasets +Manage datasets in the cloud: + +- Upload local datasets to the cloud +- Download shared datasets +- Access dataset metadata and specifications +- List available datasets (owned and shared) + +## Getting Started + +To start using Focoos HUB: + +1. **Authentication**: Set up your API key in the configuration +2. **Initialize**: Create a FocoosHUB instance +3. **Explore**: List your models and datasets +4. **Use**: Run inference or manage your ML artifacts + +See the [HUB](hub.md) section for detailed usage examples and the [Remote Inference](remote_inference.md) guide for cloud-based inference workflows. + +## Benefits + +- **Scalability**: Access to cloud GPU resources for inference +- **Collaboration**: Easy sharing of models and datasets +- **Cost Efficiency**: Pay-per-use inference without maintaining infrastructure +- **Integration**: Seamless workflow between local development and cloud deployment diff --git a/docs/hub/remote_inference.md b/docs/hub/remote_inference.md new file mode 100644 index 00000000..91e35cab --- /dev/null +++ b/docs/hub/remote_inference.md @@ -0,0 +1,198 @@ +# ๐ŸŒ Remote Inference + +Remote inference allows you to run computer vision models in the cloud without needing local GPU resources. This is perfect for production deployments, edge devices, or when you want to avoid the overhead of managing local model inference. + +## What is Remote Inference? + +Remote inference uses the Focoos cloud infrastructure to run your models. Instead of loading models locally, you send images to the cloud API and receive inference results. This provides several advantages: + +- **No Local GPU Required**: Run inference on any device, including CPU-only machines +- **Scalability**: Handle varying inference loads without managing infrastructure +- **Always Updated**: Use the latest version of your models automatically +- **Cost Efficient**: Pay per inference without maintaining dedicated hardware +- **Low Latency**: Optimized cloud infrastructure for fast inference + +## Getting Started + +### Basic Remote Inference + +Here's how to perform remote inference with a model: + +```python +from focoos import FocoosHUB + +# Initialize the HUB client +hub = FocoosHUB() + +# Get a remote model instance +model_ref = "fai-detr-l-obj365" # Use any available model +remote_model = hub.get_remote_model(model_ref) + +# Perform inference +results = remote_model.infer("path/to/image.jpg", threshold=0.5) + +# Process results +for detection in results.detections: + print(f"Class ID: {detection.cls_id}") + print(f"Confidence: {detection.conf:.3f}") + print(f"Bounding Box: {detection.bbox}") +``` + +### Using the Callable Interface + +Remote models can also be called directly like functions: + +```python +# This is equivalent to calling remote_model.infer() +results = remote_model("path/to/image.jpg", threshold=0.5) +``` + +## Supported Input Types + +Remote inference accepts various input types: + +### File Paths +```python +results = remote_model.infer("./images/photo.jpg") +``` + +### NumPy Arrays +```python +import cv2 +import numpy as np + +# Load image as numpy array +image = cv2.imread("photo.jpg") +results = remote_model.infer(image, threshold=0.3) +``` + +### PIL Images +```python +from PIL import Image + +# Load with PIL +pil_image = Image.open("photo.jpg") +results = remote_model.infer(pil_image) +``` + +### Raw Bytes +```python +# Image as bytes +with open("photo.jpg", "rb") as f: + image_bytes = f.read() + +results = remote_model.infer(image_bytes) +``` + +## Inference Parameters + +### Threshold Control + +Control detection sensitivity with the threshold parameter: + +```python +# High threshold - only very confident detections +results = remote_model.infer("image.jpg", threshold=0.8) + +# Low threshold - more detections, potentially less accurate +results = remote_model.infer("image.jpg", threshold=0.2) + +# Default threshold (usually 0.5) +results = remote_model.infer("image.jpg") +``` + +## Working with Results + +### Detection Results + +For object detection models, results contain bounding boxes and classifications: + +```python +results = remote_model.infer("image.jpg") + +print(f"Found {len(results.detections)} objects") + +for i, detection in enumerate(results.detections): + print(f"Detection {i+1}:") + print(f" Class ID: {detection.cls_id}") + print(f" Confidence: {detection.conf:.3f}") + print(f" Bounding Box: {detection.bbox}") + + # Box coordinates + if detection.bbox: + x1, y1, x2, y2 = detection.bbox[0], detection.bbox[1], detection.bbox[2], detection.bbox[3] + print(f" Coordinates: ({x1}, {y1}) to ({x2}, {y2})") +``` + +### Visualization + +Visualize results using the built-in utilities: + +```python +from focoos.utils.vision import annotate_image + +results = model.infer(image=image, threshold=0.5) + +annotated_image = annotate_image( + im=image, detections=results, task=model.model_info.task, classes=model.model_info.classes +) +``` + +## Model Management for Remote Inference + +### Checking Model Status + +Before using a model for inference, check its status: + +```python +model_info = remote_model.get_info() + +if model_info.status == ModelStatus.TRAINING_COMPLETED: + print("Model is ready for inference") + results = remote_model.infer("image.jpg") +elif model_info.status == ModelStatus.TRAINING_RUNNING: + print("Model is still training") +elif model_info.status == ModelStatus.TRAINING_ERROR: + print("Model has an error") +``` + +### Model Information + +Get detailed information about the remote model: + +```python +model_info = remote_model.get_info() + +print(f"Model: {model_info.name}") +print(f"Task: {model_info.task}") +print(f"Classes: {model_info.classes}") +print(f"Image Size: {model_info.im_size}") +print(f"Status: {model_info.status}") +``` + +## Comparison: Remote vs Local Inference + +| Aspect | Remote Inference | Local Inference | +|--------|-----------------|-----------------| +| **Hardware** | No GPU required | GPU recommended | +| **Setup** | Instant | Model download required | +| **Scalability** | Automatic | Manual scaling | +| **Cost** | Pay per use | Infrastructure costs | +| **Latency** | Network dependent | Very low | +| **Privacy** | Data sent to cloud | Data stays local | +| **Offline** | Requires internet | Works offline | + +## Best Practices + +1. **Optimize Images**: Resize large images to reduce upload time and costs +2. **Handle Errors**: Implement retry logic for network issues +3. **Batch Smartly**: Group related inferences to minimize overhead +4. **Monitor Usage**: Track inference costs and quotas +5. **Cache Results**: Store results for identical inputs when appropriate +6. **Use Appropriate Thresholds**: Tune detection thresholds for your use case + +## See Also + +- [HUB](hub.md) - Complete HUB documentation +- [Overview](overview.md) - HUB architecture overview +- [API Reference](../api/hub.md) - Detailed API documentation diff --git a/docs/index.md b/docs/index.md index 300fffab..60de13ac 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,105 +1,18 @@ -# Welcome to the Focoos AI SDK +# Welcome to the Focoos Library -Focoos AI provides a powerful development platform designed to help developers and businesses deploy high-performance, cost-efficient computer vision models. Whether you're processing images in the cloud, running inference on edge devices, or training custom models on your dataset, the Focoos AI SDK makes it seamless. +Focoos is a comprehensive library for training and deploying efficient open-source computer vision models. It enables seamless model customization on your datasets, integrates with FocoosHub for experiment tracking and model weight management, and provides easy deployment across multiple devices through simple function calls. -### Why choose Focoos AI? +### Why choose Focoos? -- ๐Ÿ”น Blazing Fast Inference. Up to 10x faster than traditional methods. -- ๐Ÿ’ฐ Optimized Cost. Requires 4x less computation, reducing cloud and hardware expenses. -- โšก Quick Deployment. Deploy and fine-tune models with minimal effort. - - -## SDK Overview -The Focoos Python SDK provides seamless access to our state-of-the-art computer vision models. With just a few lines of code, you can easily **select, customize, test, and deploy** pre-trained models tailored to your specific needs. -Whether you're deploying in the cloud or on edge devices, the Focoos Python SDK integrates smoothly into your workflow, speeding up your development process. - - -### Quickstart ๐Ÿš€ -Ready to dive in? Get started with the setup in just a few simple steps! - -**Install** the Focoos Python SDK (for more options, see [setup](setup.md)) -=== "uv" - ```bash linenums="0" - uv pip install 'focoos @ git+https://github.com/FocoosAI/focoos.git' - ``` - -=== "pip" - ```bash linenums="0" - pip install 'focoos @ git+https://github.com/FocoosAI/focoos.git' - ``` - -=== "conda" - ```bash linenums="0" - pip install 'focoos @ git+https://github.com/FocoosAI/focoos.git' - ``` - -โš™๏ธ **Customize** the models to your specific needs by [fine-tuning](howto/personalize_model.md) on your own dataset. -```python -from focoos import Focoos -from focoos.ports import Hyperparameters - -focoos = Focoos(api_key="") -model = focoos.new_model(name="awesome", - focoos_model="fai-rtdetr-m-obj365", - description="An awesome model") - -res = model.train( - dataset_ref="", - hyperparameters=Hyperparameters( - learning_rate=0.0001, - batch_size=16, - max_iters=1500, - ) -) -``` - -๐Ÿš€ **Use** the model with just few lines of [code](howto/use_model). - -```python -from focoos import Focoos -from PIL import Image -# Initialize the Focoos client with your API key -focoos = Focoos(api_key="") - -# Get the remote model from Focoos API -model = focoos.get_remote_model("") - -# Run inference on an image -detections, preview = model.infer(image_path, threshold=0.5, annotate=True) - -# Output the detections -Image.fromarray(preview) -``` - - -### Our Models ๐Ÿง  -Focoos AI offers the best models in object detection, semantic and instance segmentation, and more is coming soon. - -Using Focoos AI helps you save both time and money while delivering high-performance AI models ๐Ÿ’ช: - -- **10x Faster** ๐Ÿš€: our models are able to process images up to ten times faster than traditional methods. -- **4x Cheaper** ๐Ÿ’ฐ: our models require up to 4x less computational power, letting you save on hardware or cloud bill while ensuring high-quality results. -- **90% Time Saved** โฑ๏ธ: our platform accelerates computer vision model development and deployment, enabling faster model training, seamless integration, and optimized performance with minimal effort. - -These are not empty promises, but the result of years of research and development by our team ๐Ÿ”ฌ: -
-
- ADE-20k Semantic Segmentation -
ADE-20k Semantic Segmentation Results
-
-
- COCO Object Detection -
COCO Object Detection Results
-
-
- -See the list of our models in the [models](models.md) section. +- ๐Ÿ”น **Performance**: Achieve up to 10x faster inference speeds compared to traditional computer vision models, enabling real-time processing even on edge devices. +- ๐Ÿ’ฐ **Cost Efficiency**: Reduce computational requirements by 75%, significantly lowering cloud infrastructure and hardware costs while maintaining high accuracy. +- โšก **Developer Experience**: Streamline your workflow with an intuitive API that makes model deployment, fine-tuning, and integration seamless and straightforward. --- ## Start now! By choosing Focoos AI, you can save time, reduce costs, and achieve superior model performance, all while ensuring the privacy and efficiency of your deployments. -[Reach out to us](mailto:support@focoos.ai) to ask for your API key and power your computer vision projects. +[Reach out to us](mailto:support@focoos.ai) for any question or go to [Focoos AI](http://app.focoos.ai) to register and start using the hub. -Otherwise [Book A Demo](https://www.focoos.ai/book-a-demo) now to access the platform and test by yourself Focoos AI in action. +Otherwise [Book A Meeting](https://meetings.hubspot.com/antonio-tavera?__hstc=176038589.0ee7608bc1f1800114c59ca4f3f8aa60.1748272563717.1748978473658.1748980743440.11&__hssc=176038589.8.1748980743440&__hsfp=998098272&uuid=e4f154d1-44f7-4657-b569-3541729fcc8a) with us to see Focoos in action. diff --git a/docs/inference.md b/docs/inference.md new file mode 100644 index 00000000..9b648954 --- /dev/null +++ b/docs/inference.md @@ -0,0 +1,186 @@ +# How to Use a Computer Vision Model with Focoos +Focoos provides a powerful inference framework that makes it easy to deploy and use state-of-the-art computer vision models in production. Whether you're working on object detection, image classification, or other vision tasks, Focoos offers flexible deployment options that adapt to your specific needs. + +[![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/FocoosAI/focoos/blob/main/tutorials/inference.ipynb) + +Key features of the Focoos inference framework include: + +- **Multiple Deployment Options**: Choose between cloud-based inference, local PyTorch deployment, or optimized runtime deployment +- **Easy Model Loading**: Seamlessly load models from the Focoos Hub or your local environment +- **Production-Ready Features**: + - Optimized inference performance + - Hardware acceleration compatibility + - Memory-efficient execution +- **Simple Integration**: Easy-to-use APIs that work seamlessly with your existing applications + +In the following sections, we'll guide you through the different ways to use Focoos models for inference, from cloud deployment to local optimization. + +## ๐ŸŽจ There are three ways to use a model: + +1. [๐ŸŒ Remote Inference](#1-remote-inference) +2. [๐Ÿ”ฅ Pytorch Inference](#2-pytorch-inference) +3. [๐Ÿ”จ Optimized Inference](#3-optimized-inference) + +## 0. \[Optional\] Connect to the Focoos Hub + +Focoos can be used without having an accont on the [Focoos Hub](http://app.focoos.ai). With it, you will unlock additional functionalities, as we will see below. If you have it, just connect to the HUB. +```python +from focoos.hub import FocoosHUB + +FOCOOS_API_KEY = os.getenv("FOCOOS_API_KEY") # write here your API key os set env variable FOCOOS_API_KEY, will be used as default +hub = FocoosHUB(api_key=FOCOOS_API_KEY) +``` + +You can see the models available for you on the platform with an intuitive user interface. +However, you can also list them using the Hub functionalities. + +```python +models = hub.list_remote_models() + +``` + + +## 1. ๐ŸŒ Remote Inference + +In this section, you'll run a model on the Focoos' servers instead of on your machine. The image will be packed and sent on the network to the servers, where it is processed and the results is retured to your machine, all in few milliseconds. If you want an example model, you can try `fai-detr-l-obj365`. + + + +```python +model_ref = "" +dataset = hub.get_remote_model(model_ref) +``` + +Using the model is as simple as it could! Just call it with an image. + +```python +from PIL import Image +image = Image.open("") +detections = model(image) +``` + +`detections` is a [FocoosDetections](/focoos/api/ports/#focoos.ports.FocoosDetections) object, containing a list of [FocoosDet](/focoos/api/ports/#focoos.ports.FocoosDet) objects and optionally a dict of information about the latency of the inference. The `FocoosDet` object contains the following attributes: + +- `bbox`: Bounding box coordinates in x1y1x2y2 absolute format. +- `conf`: Confidence score (from 0 to 1). +- `cls_id`: Class ID (0-indexed). +- `label`: Label (name of the class). +- `mask`: Mask (base64 encoded string having origin in the top left corner of bbox and the same width and height of the bbox). + +If you want to visualize the result on the image, there's a utily for you. + +```python +from focoos.utils.vision import annotate_image + +annotate_image(image, detections, task=model.model_info.task, classes=model.model_info.classes).save("predictions.png") +``` + +## 2. ๐Ÿ”ฅ PyTorch Inference + +This section demonstrates how to perform local inference using a plain Pytorch model. +We will load a model and then run inference on a sample image. + +First, let's get a model. We need to use the `ModelManager` that will take care of instaciating the right model starting from a model reference (for example, the `fai-detr-l-obj365`). If you want to use a model from the Hub, please remember to add `hub://` as prefix to the model reference. + +=== "Pretrained Model" + + ```python + from focoos.model_manager import ModelManager + model_name = "fai-detr-l-obj365" + + model = ModelManager.get(model_name) + ``` + +=== "HUB Model" + +```python +from focoos.model_manager import ModelManager + +model_ref = "" + + +model = ModelManager.get(f"hub://{model_ref}") +``` + +=== "Local Model " + +```python +from focoos.model_manager import ModelManager + +model_path = "/path/to/model" + + +model = ModelManager.get(model_path) +``` + +Now, again, you can now run the model by simply passing it an image and visualize the results. + +```python +from focoos.utils.vision import annotate_image + +detections = model(image) + +annotate_image(image, detections, task=model.model_info.task, classes=model.model_info.classes).save("predictions.png") +``` + +`detections` is a [FocoosDetections](/focoos/api/ports/#focoos.ports.FocoosDetections) object. + +How fast is this model locally? We can compute it's speed by using the benchmark utility. + +```python +model.benchmark(iterations=10, size=640) +``` + +## 3. ๐Ÿ”จ Optimized Inference + +As you can see, using the torch model is great, but we can achieve better performance by exporting and running it with a optimized runtime, such as Torchscript, TensorRT, CoreML or the ones available on ONNXRuntime. + +In the following cells, we will export the previous model for one of these and run it. + +### Torchscript + +We already provide multiple inference runtime, that you can see on the [`RuntimeTypes`](focoos/api/ports/#focoos.ports.RuntimeType) enum. Let's select Torchscript as an example. + +```python +from focoos.ports import RuntimeType + +runtime = RuntimeType.TORCHSCRIPT_32 +``` + +It's time to export the model. We can use the export method of the models. + +```python +optimized_model = model.export(runtime_type=runtime, image_size=512) +``` + +Let's visualize the output. As you will see, there are not differences from the model in pure torch. + +```python +from focoos.utils.vision import annotate_image + +detections = optimized_model(image) +annotate_image(image, detections, task=model.model_info.task, classes=model.model_info.classes).save("prediction.png") +``` +`detections` is a [FocoosDetections](/focoos/api/ports/#focoos.ports.FocoosDetections) object. + + +But, let's see its latency, that should be substantially lower than the pure pytorch model. +```python +optimized_model.benchmark(iterations=10, size=512) +``` + +You can use different runtimes that may fit better your device, such as TensorRT. See the list of available Runtimes at [`RuntimeTypes`](focoos/api/ports/#focoos.ports.RuntimeType). Please note that you need to install the relative packages for onnx and tensorRT for using them. + +### ONNX with TensorRT +```python +from focoos.ports import RuntimeType +from focoos.utils.vision import annotate_image + +runtime = RuntimeType.ONNX_TRT16 +optimized_model = model.export(runtime_type=runtime) + +detections = optimized_model(image) +display(annotate_image(image, detections, task=model.model_info.task, classes=model.model_info.classes)) + +optimized_model.benchmark(iterations=10, size=640) +``` diff --git a/docs/models.md b/docs/models.md deleted file mode 100644 index b5b14e71..00000000 --- a/docs/models.md +++ /dev/null @@ -1,42 +0,0 @@ -# Focoos Models ๐Ÿง  - -With the Focoos SDK, you can take advantage of a collection of foundational models that are optimized for a range of computer vision tasks. These pre-trained models, covering detection and semantic segmentation across various domains, provide an excellent starting point for your specific use case. Whether you need to fine-tune for custom requirements or adapt them to your application, these models offer a solid foundation to accelerate your development process. - ---- - -## Semantic Segmentation ๐Ÿ–ผ๏ธ - -| Model Name | Architecture | Domain (Classes) | Dataset | Metric | FPS Nvidia-T4 | -|------------|--------------|------------------|----------|---------|--------------| -| [fai-m2f-l-ade](models/fai-m2f-l-ade.md) | [Mask2Former](https://github.com/facebookresearch/Mask2Former) ([Resnet-101](https://github.com/pytorch/vision/blob/main/torchvision/models/resnet.py)) | Common Scene (150) | [ADE20K](https://groups.csail.mit.edu/vision/datasets/ADE20K/) | mIoU: 48.27
mAcc: 62.15 | 73 | -| [fai-m2f-m-ade](models/fai-m2f-m-ade.md) | [Mask2Former](https://github.com/facebookresearch/Mask2Former) ([STDC-2](https://github.com/MichaelFan01/STDC-Seg)) | Common Scene (150) | [ADE20K](https://groups.csail.mit.edu/vision/datasets/ADE20K/) | mIoU: 45.32
mACC: 57.75 | 127 | -| [fai-m2f-s-ade](models/fai-m2f-s-ade.md) | [Mask2Former](https://github.com/facebookresearch/Mask2Former) ([STDC-1](https://github.com/MichaelFan01/STDC-Seg)) | Common Scene (150) | [ADE20K](https://groups.csail.mit.edu/vision/datasets/ADE20K/) | mIoU: 41.23
mAcc: 52.21 | 189 | - - mIoU = Intersection over Union averaged by class
- mAcc = Pixel Accuracy averaged by class
- FPS = Frames per second computed using TensorRT with resolution 640x640
- - -## Object Detection ๐Ÿ•ต๏ธโ€โ™‚๏ธ - -| Model Name | Architecture | Domain (Classes) | Dataset | Metric | FPS Nvidia-T4 | -|------------|--------------|------------------|----------|---------|--------------| -| [fai-rtdetr-l-coco](models/fai-rtdetr-l-coco.md) | [RT-DETR](https://github.com/lyuwenyu/RT-DETR) ([Resnet-50](https://github.com/pytorch/vision/blob/main/torchvision/models/resnet.py)) | Common Objects (80) | [COCO](https://cocodataset.org/#home) | bbox/AP: 53.06
bbox/AP50: 70.91 | 87 | -| [fai-rtdetr-m-coco](models/fai-rtdetr-m-coco.md) | [RT-DETR](https://github.com/lyuwenyu/RT-DETR) ([STDC-2](https://github.com/MichaelFan01/STDC-Seg)) | Common Objects (80) | [COCO](https://cocodataset.org/#home) | bbox/AP: 44.69
bbox/AP50: 61.63 | 181 | -| [fai-rtdetr-s-coco](models/fai-rtdetr-s-coco.md) | [RT-DETR](https://github.com/lyuwenyu/RT-DETR) ([STDC-1](https://github.com/MichaelFan01/STDC-Seg)) | Common Objects (80) | [COCO](https://cocodataset.org/#home) | bbox/AP: 42.58
bbox/AP50: 59.22 | 220 | -| [fai-rtdetr-n-coco](models/fai-rtdetr-n-coco.md) | [RT-DETR](https://github.com/lyuwenyu/RT-DETR) ([STDC-1](https://github.com/MichaelFan01/STDC-Seg)) | Common Objects (80) | [COCO](https://cocodataset.org/#home) | bbox/AP: 40.59
bbox/AP50: 56.69 | 269 | -| [fai-rtdetr-m-obj365](models/fai-rtdetr-m-obj365.md) | [RT-DETR](https://github.com/lyuwenyu/RT-DETR) ([Resnet50](https://github.com/pytorch/vision/blob/main/torchvision/models/resnet.py)) | Common Objects (365) | [Objects365](https://www.objects365.org/overview.html) | bbox/AP: 34.60
bbox/AP50: 45.81 | 87 | - - AP = Average Precision averaged by class
- AP50 = Average Precision at IoU threshold 0.50 averaged by class
- FPS = Frames per second computed using TensorRT with resolution 640x640
- -## Instance Segmentation ๐ŸŽญ - -| Model Name | Architecture | Domain (Classes) | Dataset | Metric | FPS Nvidia-T4 | -|------------|--------------|------------------|----------|---------|--------------| -| [fai-m2f-l-coco-ins](models/fai-m2f-l-coco-ins.md) | [Mask2Former](https://github.com/facebookresearch/Mask2Former) ([Resnet-50](https://github.com/pytorch/vision/blob/main/torchvision/models/resnet.py)) | Common Objects (80) | [COCO](https://cocodataset.org/#home) | segm/AP: 42.39
segm/AP50: 66.12 | 54 | - - AP = Average Precision averaged by class
- AP50 = Average Precision at IoU threshold 0.50 averaged by class
- FPS = Frames per second computed using TensorRT with resolution 640x640
diff --git a/docs/models/bisenetformer.md b/docs/models/bisenetformer.md new file mode 100644 index 00000000..4fc6c544 --- /dev/null +++ b/docs/models/bisenetformer.md @@ -0,0 +1,160 @@ +# BisenetFormer (Bilateral Segmentation Network with Transformer) + +## Overview + +BisenetFormer is an advanced semantic segmentation model that combines the efficiency of BiSeNet (Bilateral Segmentation Network) with the power of transformer architectures. Developed by FocoosAI, this model is designed for real-time semantic segmentation tasks requiring both high accuracy and computational efficiency. + +The model employs a dual-path architecture where spatial details are preserved through one path while semantic information is processed through another, then fused with transformer-based attention mechanisms for superior segmentation performance. + +## Neural Network Architecture + +The BisenetFormer architecture consists of four main components working in concert: + +### Backbone +- **Purpose**: Feature extraction from input images +- **Design**: Configurable backbone network (e.g., ResNet, STDC) +- **Output**: Multi-scale features at different resolutions (1/4, 1/8, 1/16, 1/32) + +### Context Path +- **Component**: Global context extraction path +- **Features**: + - Attention Refinement Module (ARM) for feature enhancement + - Global Average Pooling for context aggregation + - Multi-scale feature fusion with upsampling +- **Purpose**: Captures high-level semantic information + +### Spatial Path (Detail Branch) +- **Component**: Spatial detail preservation path +- **Features**: + - Bilateral structure maintaining spatial resolution + - ConvBNReLU blocks for efficient processing + - Feature Fusion Module (FFM) for combining paths +- **Purpose**: Preserves fine-grained spatial details + +### Transformer Decoder +- **Design**: Lightweight transformer decoder with attention mechanisms +- **Components**: + - Self-attention layers for feature refinement + - Cross-attention layers for multi-scale feature integration + - Feed-forward networks (FFN) for feature transformation + - 100 learnable object queries +- **Layers**: Configurable number of decoder layers (default: 6) + +## Configuration Parameters + +### Core Model Parameters +- `num_classes` (int): Number of segmentation classes +- `num_queries` (int, default=100): Number of learnable object queries +- `backbone_config` (BackboneConfig): Backbone network configuration + +### Image Preprocessing +- `pixel_mean` (List[float]): RGB normalization means [123.675, 116.28, 103.53] +- `pixel_std` (List[float]): RGB normalization standard deviations [58.395, 57.12, 57.375] +- `size_divisibility` (int, default=0): Input size divisibility constraint + +### Architecture Dimensions +- `pixel_decoder_out_dim` (int, default=256): Pixel decoder output channels +- `pixel_decoder_feat_dim` (int, default=256): Pixel decoder feature channels +- `transformer_predictor_hidden_dim` (int, default=256): Transformer hidden dimension +- `transformer_predictor_dec_layers` (int, default=6): Number of decoder layers +- `transformer_predictor_dim_feedforward` (int, default=1024): FFN dimension +- `head_out_dim` (int, default=256): Prediction head output dimension + +### Inference Configuration +- `postprocessing_type` (str): Either "semantic" or "instance" segmentation +- `mask_threshold` (float, default=0.5): Binary mask threshold +- `threshold` (float, default=0.5): Confidence threshold for detections +- `top_k` (int, default=300): Maximum number of detections to return +- `use_mask_score` (bool, default=False): Whether to use mask quality scores +- `predict_all_pixels` (bool, default=False): Predict class for every pixel +- `cls_sigmoid` (bool, default=False): Use sigmoid activation for classification + +### Loss Configuration +- `criterion_deep_supervision` (bool, default=True): Enable deep supervision +- `criterion_eos_coef` (float, default=0.1): End-of-sequence coefficient +- `criterion_num_points` (int, default=12544): Number of sampling points +- `weight_dict_loss_ce` (int, default=2): Cross-entropy loss weight +- `weight_dict_loss_mask` (int, default=5): Mask loss weight +- `weight_dict_loss_dice` (int, default=5): Dice loss weight + +### Hungarian Matcher Configuration +- `matcher_cost_class` (int, default=2): Classification cost for matching +- `matcher_cost_mask` (int, default=5): Mask cost for matching +- `matcher_cost_dice` (int, default=5): Dice cost for matching + +## Supported Tasks + +### Semantic Segmentation +- **Output**: Dense pixel-wise class predictions +- **Use Cases**: Scene understanding, autonomous driving, medical imaging +- **Configuration**: Set `postprocessing_type="semantic"` + +### Instance Segmentation +- **Output**: Individual object instances with masks and bounding boxes +- **Use Cases**: Object detection and counting, robotics applications +- **Configuration**: Set `postprocessing_type="instance"` + +## Model Outputs + +### Training Output (`BisenetFormerOutput`) +- `masks` (torch.Tensor): Shape [B, num_queries, H, W] - Query mask predictions +- `logits` (torch.Tensor): Shape [B, num_queries, num_classes] - Class predictions +- `loss` (Optional[dict]): Training losses including: + - `loss_ce`: Cross-entropy classification loss + - `loss_mask`: Binary cross-entropy mask loss + - `loss_dice`: Dice coefficient loss + +### Inference Output (`FocoosDetections`) +For each detected object: +- `bbox` (List[float]): Bounding box coordinates [x1, y1, x2, y2] +- `conf` (float): Confidence score +- `cls_id` (int): Class identifier +- `mask` (str): Base64-encoded binary mask +- `label` (Optional[str]): Human-readable class name + +## Key Features + +### Efficiency Optimizations +- **Bilateral Architecture**: Separate paths for spatial details and semantic context +- **Attention Refinement**: ARM modules enhance feature quality without computational overhead +- **Lightweight Transformer**: Reduced decoder complexity for faster inference + +### Performance Advantages +- **Real-time Capable**: Optimized for efficient inference +- **Multi-scale Processing**: Leverages features at multiple resolutions +- **Context Preservation**: Global context path maintains semantic understanding +- **Detail Retention**: Spatial path preserves fine-grained details + +### Training Features +- **Deep Supervision**: Auxiliary losses at multiple decoder layers +- **Hungarian Matching**: Optimal assignment between predictions and ground truth +- **Flexible Loss Functions**: Combines classification, mask, and shape-aware losses + +## Architecture Innovations + +The BisenetFormer introduces several key innovations: + +1. **Hybrid Architecture**: Combines the efficiency of bilateral networks with transformer attention +2. **Feature Fusion Module**: Intelligent fusion of spatial and context paths +3. **Attention Refinement**: ARM modules refine features at multiple scales +4. **Query-based Segmentation**: Transformer queries enable instance-aware segmentation + +This architecture achieves an optimal balance between accuracy and efficiency, making it suitable for both research and production deployments requiring real-time semantic segmentation capabilities. + +## Available Models +Currently, you can find 5 fai-mf models on the Focoos Hub, 2 for semantic segmentation and 3 for instance-segmentation. + +### Semantic Segmentation Models + +| Model Name | Architecture | Dataset | Metric | FPS Nvidia-T4 | +|------------|--------------|----------|---------|--------------| +| fai-mf-l-ade | Mask2Former (Resnet-101) | ADE20K | mIoU: 48.27
mAcc: 62.15 | 73 | +| fai-mf-m-ade | Mask2Former (STDC-2) | ADE20K | mIoU: 45.32
mACC: 57.75 | 127 | + +### Instance Segmentation Models + +| Model Name | Architecture | Dataset | Metric | FPS Nvidia-T4 | +|------------|--------------|----------|---------|--------------| +| fai-m2f-s-coco-ins | Mask2Former (Resnet-50) | COCO | segm/AP: 41.45
segm/AP50: 64.12 | 86 | +| fai-m2f-m-coco-ins | Mask2Former (Resnet-101) | COCO | segm/AP: 43.09
segm/AP50: 65.87 | 70 | +| fai-m2f-l-coco-ins | Mask2Former (Resnet-101) | COCO | segm/AP: 44.23
segm/AP50: 67.53 | 55 | diff --git a/docs/models/fai-cls.md b/docs/models/fai-cls.md new file mode 100644 index 00000000..addff35c --- /dev/null +++ b/docs/models/fai-cls.md @@ -0,0 +1,165 @@ +# FAI-CLS (FocoosAI Classification) + +## Overview + +FAI-CLS is a versatile image classification model developed by FocoosAI that can utilize any backbone architecture for feature extraction. This model is designed for both single-label and multi-label image classification tasks, offering flexibility in architecture choices and training configurations. + +The model employs a simple yet effective approach: a configurable backbone extracts features from input images, followed by a classification head that produces class predictions. This design enables easy adaptation to different domains and datasets while maintaining high performance and computational efficiency. + +## Neural Network Architecture + +The FAI-CLS architecture consists of two main components: + +### Backbone +- **Purpose**: Feature extraction from input images +- **Design**: Configurable backbone network (ResNet, EfficientNet, STDC, etc.) +- **Output**: High-level feature representations +- **Feature Selection**: Uses specified feature level (default: "res5" for highest-level features) +- **Flexibility**: Supports any backbone that provides the required output shape + +### Classification Head +- **Architecture**: Multi-layer perceptron (MLP) with configurable depth +- **Components**: + - Global Average Pooling (AdaptiveAvgPool2d) for spatial dimension reduction + - Flatten layer to convert 2D features to 1D + - Linear layers with ReLU activation + - Dropout for regularization + - Final linear layer for class predictions +- **Configurations**: + - **Single Layer**: Direct mapping from features to classes + - **Two Layer**: Hidden layer with ReLU and dropout for better feature transformation + +## Configuration Parameters + +### Core Model Parameters +- `num_classes` (int): Number of classification classes +- `backbone_config` (BackboneConfig): Backbone network configuration +- `resolution` (int, default=224): Input image resolution + +### Image Preprocessing +- `pixel_mean` (List[float]): RGB normalization means [123.675, 116.28, 103.53] +- `pixel_std` (List[float]): RGB normalization standard deviations [58.395, 57.12, 57.375] + +### Architecture Configuration +- `hidden_dim` (int, default=512): Hidden layer dimension for two-layer classifier +- `dropout_rate` (float, default=0.2): Dropout probability for regularization +- `features` (str, default="res5"): Feature level to extract from backbone +- `num_layers` (int, default=2): Number of classification layers (1 or 2) + +### Loss Configuration +- `use_focal_loss` (bool, default=False): Use focal loss instead of cross-entropy +- `focal_alpha` (float, default=0.75): Alpha parameter for focal loss +- `focal_gamma` (float, default=2.0): Gamma parameter for focal loss +- `label_smoothing` (float, default=0.0): Label smoothing factor +- `multi_label` (bool, default=False): Enable multi-label classification + +## Supported Tasks + +### Single-Label Classification +- **Output**: Single class prediction per image +- **Use Cases**: + - Image categorization (animals, objects, scenes) + - Medical image diagnosis + - Quality control in manufacturing + - Content moderation + - Agricultural crop classification +- **Loss**: Cross-entropy or focal loss +- **Configuration**: Set `multi_label=False` + +### Multi-Label Classification +- **Output**: Multiple class predictions per image +- **Use Cases**: + - Multi-object recognition + - Image tagging and annotation + - Scene attribute recognition + - Medical condition classification + - Content-based image retrieval +- **Loss**: Binary cross-entropy with logits +- **Configuration**: Set `multi_label=True` + +## Model Outputs + +### Training Output (`ClassificationModelOutput`) +- `logits` (torch.Tensor): Shape [B, num_classes] - Raw class predictions +- `loss` (Optional[dict]): Training loss including: + - `loss_cls`: Classification loss (cross-entropy, focal, or BCE) + +### Inference Output +- **Single-Label**: Class probabilities after softmax activation +- **Multi-Label**: Class probabilities after sigmoid activation +- **Post-Processing**: Can be processed into `FocoosDetections` format with confidence scores + +## Key Features + +### Flexibility +- **Backbone Agnostic**: Compatible with any feature extraction backbone +- **Configurable Depth**: Choose between 1 or 2-layer classification heads +- **Multi-Task Ready**: Supports both single-label and multi-label scenarios +- **Resolution Adaptive**: Configurable input resolution for different use cases + +### Training Features +- **Advanced Loss Functions**: Focal loss for handling class imbalance +- **Label Smoothing**: Reduces overfitting and improves generalization +- **Dropout Regularization**: Prevents overfitting in the classification head +- **Multi-Label Support**: Binary cross-entropy for multi-label scenarios + +### Performance Optimizations +- **Efficient Head Design**: Lightweight classification layers +- **Global Average Pooling**: Reduces spatial dimensions efficiently +- **Proper Initialization**: Truncated normal initialization for better training + +## Loss Functions + +The model supports multiple loss function configurations: + +### Cross-Entropy Loss (Default) +- **Use Case**: Standard single-label classification +- **Features**: Optional label smoothing for better generalization +- **Activation**: Softmax for probability distribution + +### Focal Loss +- **Use Case**: Imbalanced datasets with hard-to-classify examples +- **Parameters**: + - Alpha (ฮฑ): Controls importance of rare class + - Gamma (ฮณ): Focuses learning on hard examples +- **Benefits**: Improved performance on imbalanced datasets + +### Binary Cross-Entropy Loss +- **Use Case**: Multi-label classification tasks +- **Features**: Independent probability for each class +- **Activation**: Sigmoid for per-class probabilities + +## Architecture Variants + +### Single-Layer Classifier +``` +AdaptiveAvgPool2d(1) โ†’ Flatten โ†’ Dropout โ†’ Linear(features โ†’ num_classes) +``` +- **Benefits**: Faster inference, fewer parameters +- **Use Case**: Simple datasets or when computational efficiency is critical + +### Two-Layer Classifier +``` +AdaptiveAvgPool2d(1) โ†’ Flatten โ†’ Linear(features โ†’ hidden_dim) โ†’ ReLU โ†’ Dropout โ†’ Linear(hidden_dim โ†’ num_classes) +``` +- **Benefits**: Better feature transformation, improved accuracy +- **Use Case**: Complex datasets requiring more sophisticated feature processing + +## Training Strategies + +### Standard Training +- Use cross-entropy loss with appropriate learning rate scheduling +- Apply data augmentation for better generalization +- Monitor validation accuracy for early stopping + +### Imbalanced Data +- Enable focal loss with appropriate ฮฑ and ฮณ parameters +- Consider class weighting strategies +- Use stratified sampling for validation + +### Multi-Label Scenarios +- Set `multi_label=True` in configuration +- Use appropriate evaluation metrics (F1-score, mAP) +- Consider threshold optimization for final predictions + +This flexible architecture makes FAI-CLS suitable for a wide range of image classification applications, from simple binary classification to complex multi-label scenarios, while maintaining computational efficiency and ease of use. diff --git a/docs/models/fai-rtdetr-l-coco.md b/docs/models/fai-detr-l-coco.md similarity index 98% rename from docs/models/fai-rtdetr-l-coco.md rename to docs/models/fai-detr-l-coco.md index 83124c6e..1408503c 100644 --- a/docs/models/fai-rtdetr-l-coco.md +++ b/docs/models/fai-detr-l-coco.md @@ -1,4 +1,4 @@ -# fai-rtdetr-l-coco +# fai-detr-l-coco ## Overview The models is the reimplementation of the [RT-DETR](https://github.com/lyuwenyu/RT-DETR) model by [FocoosAI](https://focoos.ai) for the [COCO dataset](https://cocodataset.org/#home). It is a object detection model able to detect 80 thing (dog, cat, car, etc.) classes. @@ -496,8 +496,8 @@ import os # Initialize the Focoos client with your API key focoos = Focoos(api_key=os.getenv("FOCOOS_API_KEY")) -# Get the remote model (fai-rtdetr-l-coco) from Focoos API -model = focoos.get_remote_model("fai-rtdetr-l-coco") +# Get the remote model (fai-detr-l-coco) from Focoos API +model = focoos.get_remote_model("fai-detr-l-coco") # Run inference on an image predictions = model.infer("./image.jpg", threshold=0.5) diff --git a/docs/models/fai-rtdetr-m-obj365.md b/docs/models/fai-detr-l-obj365.md similarity index 99% rename from docs/models/fai-rtdetr-m-obj365.md rename to docs/models/fai-detr-l-obj365.md index 234d053b..af0c0d41 100644 --- a/docs/models/fai-rtdetr-m-obj365.md +++ b/docs/models/fai-detr-l-obj365.md @@ -1,4 +1,4 @@ -# fai-rtdetr-m-obj365 +# fai-detr-l-obj365 ## Overview The models is a [RT-DETR](https://github.com/lyuwenyu/RT-DETR) model otimized by [FocoosAI](https://focoos.ai) for the [Objects365](https://www.objects365.org/overview.html). It is a object detection model able to detect 365 thing (dog, cat, car, etc.) classes. @@ -1916,8 +1916,8 @@ import os # Initialize the Focoos client with your API key focoos = Focoos(api_key=os.getenv("FOCOOS_API_KEY")) -# Get the remote model (fai-rtdetr-s-coco) from Focoos API -model = focoos.get_remote_model("fai-rtdetr-m-obj365") +# Get the remote model (fai-detr-s-coco) from Focoos API +model = focoos.get_remote_model("fai-detr-l-obj365") # Run inference on an image predictions = model.infer("./image.jpg", threshold=0.5) diff --git a/docs/models/fai-rtdetr-m-coco.md b/docs/models/fai-detr-m-coco.md similarity index 98% rename from docs/models/fai-rtdetr-m-coco.md rename to docs/models/fai-detr-m-coco.md index 9bd6f4c4..d977deb9 100644 --- a/docs/models/fai-rtdetr-m-coco.md +++ b/docs/models/fai-detr-m-coco.md @@ -1,4 +1,4 @@ -# fai-rtdetr-m-coco +# fai-detr-m-coco ## Overview The models is a [RT-DETR](https://github.com/lyuwenyu/RT-DETR) model otimized by [FocoosAI](https://focoos.ai) for the [COCO dataset](https://cocodataset.org/#home). It is a object detection model able to detect 80 thing (dog, cat, car, etc.) classes. @@ -496,8 +496,8 @@ import os # Initialize the Focoos client with your API key focoos = Focoos(api_key=os.getenv("FOCOOS_API_KEY")) -# Get the remote model (fai-rtdetr-m-coco) from Focoos API -model = focoos.get_remote_model("fai-rtdetr-m-coco") +# Get the remote model (fai-detr-m-coco) from Focoos API +model = focoos.get_remote_model("fai-detr-m-coco") # Run inference on an image predictions = model.infer("./image.jpg", threshold=0.5) diff --git a/docs/models/fai-detr.md b/docs/models/fai-detr.md new file mode 100644 index 00000000..3980e0c6 --- /dev/null +++ b/docs/models/fai-detr.md @@ -0,0 +1,173 @@ +# FAI-DETR (FocoosAI Detection Transformer) + +## Overview + +FAI-DETR is an advanced object detection model based on the DETR (Detection Transformer) architecture, optimized by FocoosAI for efficient and accurate object detection tasks. This model eliminates the need for hand-crafted components like non-maximum suppression (NMS) and anchor generation by using a transformer-based approach with learnable object queries. + +The model employs a set-based global loss through bipartite matching and a transformer encoder-decoder architecture that directly predicts bounding boxes and class labels. This end-to-end approach simplifies the detection pipeline while achieving competitive performance. + +## Neural Network Architecture + +The FAI-DETR architecture consists of four main components: + +### Backbone +- **Purpose**: Feature extraction from input images +- **Design**: Configurable backbone network (ResNet, STDC, etc.) +- **Output**: Multi-scale features at different resolutions +- **Integration**: Features are processed through the encoder for global context + +### Encoder +- **Architecture**: Transformer encoder with multi-scale deformable attention +- **Components**: + - Multi-scale deformable self-attention layers + - Position embeddings (sine-based) + - Feed-forward networks (FFN) + - CSPRep layers for efficient feature processing +- **Features**: Processes multi-scale features to capture global context +- **Layers**: Configurable number of encoder layers (default: 1) + +### Decoder +- **Architecture**: Multi-scale deformable transformer decoder +- **Components**: + - Self-attention layers for query interaction + - Cross-attention layers with deformable attention + - Feed-forward networks + - Reference point refinement +- **Queries**: 300 learnable object queries (configurable) +- **Layers**: Configurable number of decoder layers (default: 6) + +### Detection Head +- **Classification Head**: Predicts class probabilities for each query +- **Regression Head**: Predicts bounding box coordinates (center, width, height) +- **Output Format**: Direct box predictions without anchors or post-processing + +## Configuration Parameters + +### Core Model Parameters +- `num_classes` (int): Number of object detection classes +- `num_queries` (int, default=300): Number of learnable object queries +- `resolution` (int, default=640): Input image resolution +- `backbone_config` (BackboneConfig): Backbone network configuration + +### Image Preprocessing +- `pixel_mean` (List[float]): RGB normalization means [123.675, 116.28, 103.53] +- `pixel_std` (List[float]): RGB normalization standard deviations [58.395, 57.12, 57.375] +- `size_divisibility` (int, default=0): Input size divisibility constraint + +### Encoder Configuration +- `pixel_decoder_out_dim` (int, default=256): Encoder output dimension +- `pixel_decoder_feat_dim` (int, default=256): Encoder feature dimension +- `pixel_decoder_num_encoder_layers` (int, default=1): Number of encoder layers +- `pixel_decoder_expansion` (float, default=1.0): Channel expansion ratio +- `pixel_decoder_dim_feedforward` (int, default=1024): FFN dimension +- `pixel_decoder_dropout` (float, default=0.0): Dropout rate +- `pixel_decoder_nhead` (int, default=8): Number of attention heads + +### Decoder Configuration +- `transformer_predictor_hidden_dim` (int, default=256): Decoder hidden dimension +- `transformer_predictor_dec_layers` (int, default=6): Number of decoder layers +- `transformer_predictor_dim_feedforward` (int, default=1024): FFN dimension +- `transformer_predictor_nhead` (int, default=8): Number of attention heads +- `transformer_predictor_out_dim` (int, default=256): Decoder output dimension +- `head_out_dim` (int, default=256): Detection head output dimension + +### Inference Configuration +- `threshold` (float, default=0.5): Confidence threshold for detections +- `top_k` (int, default=300): Maximum number of detections to return + +### Loss Configuration +- `criterion_deep_supervision` (bool, default=True): Enable deep supervision +- `criterion_eos_coef` (float, default=0.1): End-of-sequence coefficient +- `criterion_losses` (List[str]): Loss types ["vfl", "boxes"] +- `criterion_focal_alpha` (float, default=0.75): Focal loss alpha parameter +- `criterion_focal_gamma` (float, default=2.0): Focal loss gamma parameter +- `weight_dict_loss_vfl` (int, default=1): Varifocal loss weight +- `weight_dict_loss_bbox` (int, default=5): Bounding box loss weight +- `weight_dict_loss_giou` (int, default=2): GIoU loss weight + +### Hungarian Matcher Configuration +- `matcher_cost_class` (int, default=2): Classification cost for matching +- `matcher_cost_bbox` (int, default=5): Bounding box cost for matching +- `matcher_cost_giou` (int, default=2): GIoU cost for matching +- `matcher_use_focal_loss` (bool, default=True): Use focal loss in matcher +- `matcher_alpha` (float, default=0.25): Matcher focal loss alpha +- `matcher_gamma` (float, default=2.0): Matcher focal loss gamma + +## Supported Tasks + +### Object Detection +- **Output**: Bounding boxes with class labels and confidence scores +- **Use Cases**: + - General object detection in natural images + - Autonomous driving (vehicle, pedestrian detection) + - Surveillance and security applications + - Industrial quality control + - Medical image analysis +- **Performance**: End-to-end detection without NMS post-processing + +## Model Outputs + +### Training Output (`DETRModelOutput`) +- `boxes` (torch.Tensor): Shape [B, num_queries, 4] - Bounding boxes in XYXY format normalized to [0, 1] +- `logits` (torch.Tensor): Shape [B, num_queries, num_classes] - Class predictions +- `loss` (Optional[dict]): Training losses including: + - `loss_vfl`: Varifocal loss for classification + - `loss_bbox`: L1 loss for bounding box regression + - `loss_giou`: Generalized IoU loss for box alignment + +### Inference Output (`FocoosDetections`) +For each detected object: +- `bbox` (List[float]): Bounding box coordinates [x1, y1, x2, y2] +- `conf` (float): Confidence score +- `cls_id` (int): Class identifier +- `label` (Optional[str]): Human-readable class name + +## Key Features + +### Architecture Advantages +- **End-to-End Training**: Direct optimization of detection metrics +- **No Hand-Crafted Components**: Eliminates anchors, NMS, and heuristic post-processing +- **Set-Based Global Loss**: Bipartite matching enables global optimization +- **Parallel Prediction**: All objects predicted simultaneously + +### Performance Optimizations +- **Deformable Attention**: Efficient multi-scale feature processing +- **CSP Layers**: Channel Split Pooling for computational efficiency +- **RepVGG Blocks**: Efficient convolution blocks for feature extraction +- **Focal Loss Variants**: Improved handling of class imbalance + +### Training Features +- **Hungarian Matching**: Optimal assignment between predictions and ground truth +- **Deep Supervision**: Auxiliary losses at each decoder layer +- **Multi-Scale Training**: Robust to different object sizes +- **Flexible Loss Combination**: Balances classification and localization objectives + +## Loss Functions + +The model employs three main loss components: + +1. **Varifocal Loss (`loss_vfl`)**: + - Advanced focal loss variant for classification + - Handles foreground-background imbalance + - Joint optimization of classification and localization quality + +2. **Bounding Box Loss (`loss_bbox`)**: + - L1 loss for direct coordinate regression + - Normalized coordinates for scale invariance + +3. **Generalized IoU Loss (`loss_giou`)**: + - Shape-aware bounding box loss + - Better gradient flow for overlapping boxes + - Improved localization accuracy + +## Architecture Innovations + +The FAI-DETR introduces several key innovations: + +1. **Efficient Encoder Design**: Lightweight encoder with deformable attention +2. **Multi-Scale Processing**: Handles objects at different scales effectively +3. **Reference Point Refinement**: Iterative improvement of object localization +4. **CSP Integration**: Efficient feature processing with Channel Split Pooling +5. **Optimized Matching**: Hungarian algorithm for optimal query-target assignment + +This architecture achieves an optimal balance between accuracy and efficiency, making it suitable for both research applications and production deployments requiring fast and accurate object detection capabilities. diff --git a/docs/models/fai-m2f-l-ade.md b/docs/models/fai-mf-l-ade.md similarity index 98% rename from docs/models/fai-m2f-l-ade.md rename to docs/models/fai-mf-l-ade.md index 04f39d82..3c017e56 100644 --- a/docs/models/fai-m2f-l-ade.md +++ b/docs/models/fai-mf-l-ade.md @@ -1,4 +1,4 @@ -# fai-m2f-l-ade +# fai-mf-l-ade ## Overview The models is a [Mask2Former](https://github.com/facebookresearch/Mask2Former) model otimized by [FocoosAI](https://focoos.ai) for the [ADE20K dataset](https://groups.csail.mit.edu/vision/datasets/ADE20K/). It is a semantic segmentation model able to segment 150 classes, comprising both stuff (sky, road, etc.) and thing (dog, cat, car, etc.). @@ -17,9 +17,9 @@ Note: FPS are computed on NVIDIA T4 using TensorRT and image size 640x640. | SegFormerB5 | 49.6 | 27 | | MaskFormer (R50) | 44.3 | 68 | | Mask2Former (R50) | 47.2 | 21.5 | -| [fai-m2f-s-ade](models/fai-m2f-s-ade.md) | 41.23 | 189 | -| [fai-m2f-m-ade](models/fai-m2f-m-ade.md) | 45.32 | 127 | -| **fai-m2f-l-ade** | **48.27** | **73** | --> +| [fai-mf-s-ade](models/fai-mf-s-ade.md) | 41.23 | 189 | +| [fai-mf-m-ade](models/fai-mf-m-ade.md) | 45.32 | 127 | +| **fai-mf-l-ade** | **48.27** | **73** | --> ## Model Details @@ -261,8 +261,8 @@ import os # Initialize the Focoos client with your API key focoos = Focoos(api_key=os.getenv("FOCOOS_API_KEY")) -# Get the remote model (fai-m2f-l-ade) from Focoos API -model = focoos.get_remote_model("fai-m2f-l-ade") +# Get the remote model (fai-mf-l-ade) from Focoos API +model = focoos.get_remote_model("fai-mf-l-ade") # Run inference on an image predictions = model.infer("./image.jpg", threshold=0.5) diff --git a/docs/models/fai-m2f-l-coco-ins.md b/docs/models/fai-mf-l-coco-ins.md similarity index 98% rename from docs/models/fai-m2f-l-coco-ins.md rename to docs/models/fai-mf-l-coco-ins.md index cf5b5e73..313c76d4 100644 --- a/docs/models/fai-m2f-l-coco-ins.md +++ b/docs/models/fai-mf-l-coco-ins.md @@ -1,4 +1,4 @@ -# fai-m2f-l-coco-ins +# fai-mf-l-coco-ins ## Overview The models is a [Mask2Former](https://github.com/facebookresearch/Mask2Former) model otimized by [FocoosAI](https://focoos.ai) for the [COCO dataset](https://cocodataset.org/#home). It is an instance segmentation model able to segment 80 thing (dog, cat, car, etc.) classes. @@ -491,8 +491,8 @@ import os # Initialize the Focoos client with your API key focoos = Focoos(api_key=os.getenv("FOCOOS_API_KEY")) -# Get the remote model (fai-m2f-l-coco-ins) from Focoos API -model = focoos.get_remote_model("fai-m2f-l-coco-ins") +# Get the remote model (fai-mf-l-coco-ins) from Focoos API +model = focoos.get_remote_model("fai-mf-l-coco-ins") # Run inference on an image predictions = model.infer("./image.jpg", threshold=0.5) diff --git a/docs/models/fai-m2f-m-ade.md b/docs/models/fai-mf-m-ade.md similarity index 99% rename from docs/models/fai-m2f-m-ade.md rename to docs/models/fai-mf-m-ade.md index de1c77a3..19756f5b 100644 --- a/docs/models/fai-m2f-m-ade.md +++ b/docs/models/fai-mf-m-ade.md @@ -1,4 +1,4 @@ -# fai-m2f-m-ade +# fai-mf-m-ade ## Overview The models is a [Mask2Former](https://github.com/facebookresearch/Mask2Former) model otimized by [FocoosAI](https://focoos.ai) for the [ADE20K dataset](https://groups.csail.mit.edu/vision/datasets/ADE20K/). It is a semantic segmentation model able to segment 150 classes, comprising both stuff (sky, road, etc.) and thing (dog, cat, car, etc.). @@ -847,8 +847,8 @@ import os # Initialize the Focoos client with your API key focoos = Focoos(api_key=os.getenv("FOCOOS_API_KEY")) -# Get the remote model (fai-m2f-m-ade) from Focoos API -model = focoos.get_remote_model("fai-m2f-m-ade") +# Get the remote model (fai-mf-m-ade) from Focoos API +model = focoos.get_remote_model("fai-mf-m-ade") # Run inference on an image predictions = model.infer("./image.jpg", threshold=0.5) diff --git a/docs/models/fai-m2f-s-ade.md b/docs/models/fai-mf-s-ade.md similarity index 99% rename from docs/models/fai-m2f-s-ade.md rename to docs/models/fai-mf-s-ade.md index 45028a16..e1c28413 100644 --- a/docs/models/fai-m2f-s-ade.md +++ b/docs/models/fai-mf-s-ade.md @@ -1,4 +1,4 @@ -# fai-m2f-l-ade +# fai-mf-l-ade ## Overview The models is a [Mask2Former](https://github.com/facebookresearch/Mask2Former) model otimized by [FocoosAI](https://focoos.ai) for the [ADE20K dataset](https://groups.csail.mit.edu/vision/datasets/ADE20K/). It is a semantic segmentation model able to segment 150 classes, comprising both stuff (sky, road, etc.) and thing (dog, cat, car, etc.). @@ -847,8 +847,8 @@ import os # Initialize the Focoos client with your API key focoos = Focoos(api_key=os.getenv("FOCOOS_API_KEY")) -# Get the remote model (fai-m2f-s-ade) from Focoos API -model = focoos.get_remote_model("fai-m2f-s-ade") +# Get the remote model (fai-mf-s-ade) from Focoos API +model = focoos.get_remote_model("fai-mf-s-ade") # Run inference on an image predictions = model.infer("./image.jpg", threshold=0.5) diff --git a/docs/models/fai-rtdetr-n-coco.md b/docs/models/fai-rtdetr-n-coco.md deleted file mode 100644 index fc353059..00000000 --- a/docs/models/fai-rtdetr-n-coco.md +++ /dev/null @@ -1,507 +0,0 @@ -# fai-rtdetr-n-coco - -## Overview -The models is a [RT-DETR](https://github.com/lyuwenyu/RT-DETR) model otimized by [FocoosAI](https://focoos.ai) for the [COCO dataset](https://cocodataset.org/#home). It is a object detection model able to detect 80 thing (dog, cat, car, etc.) classes. - - -## Benchmark -![Benchmark Comparison](./fai-coco.png) -Note: FPS are computed on NVIDIA T4 using TensorRT and image size 640x640. - -## Model Details -The model is based on the [RT-DETR](https://github.com/lyuwenyu/RT-DETR) architecture. It is a object detection model that uses a transformer-based encoder-decoder architecture. - -### Neural Network Architecture -The [RT-DETR](https://github.com/lyuwenyu/RT-DETR) FocoosAI implementation optimize the original neural network architecture for improving the model's efficiency and performance. The original model is fully described in this [paper](https://arxiv.org/abs/2304.08069). - -RT-DETR is a hybrid model that uses three main components: a *backbone* for extracting features, an *encoder* for upscaling the features, and a *transformer-based decoder* for generating the detection output. - -![alt text](./rt-detr.png) - -In this implementation: - -- the backbone is [STDC-1](https://github.com/MichaelFan01/STDC-Seg) that shows an amazing speed while maintaining a satisfactory accuracy. -- the encoder is a bi-FPN (bilinear feature pyramid network). With respect to the original paper, we removed the attention modules in the encoder and we reduce the internal features dimension, speeding up the inference while only marginally affecting the accuracy. -- the transformer decoder is a lighter version of the original, having only 3 decoder layers, instead of 6, and we select 300 queries. - -### Losses -We use the same losses as the original paper: - -- loss_vfl: a variant of the binary cross entropy loss for the classification of the classes that is weighted by the correctness of the predicted bounding boxes IoU. -- loss_bbox: an L1 loss computing the distance between the predicted bounding boxes and the ground truth bounding boxes. -- loss_giou: a loss minimizing the IoU the predicted bounding boxes and the ground truth bounding boxes. for more details look here: [GIoU](https://giou.stanford.edu/). - -These losses are applied to each output of the transformer decoder, meaning that we apply it on the output and on each auxiliary output of the transformer decoder layers. -Please refer to the [RT-DETR paper](https://arxiv.org/abs/2304.08069) for more details. - -### Output Format -The pre-processed output of the model is set of bounding boxes with associated class probabilities. In particular, the output is composed by three tensors: - -- class_ids: a tensor of 300 elements containing the class id associated with each bounding box (such as 1 for wall, 2 for building, etc.) -- scores: a tensor of 300 elements containing the corresponding probability of the class_id -- boxes: a tensor of shape (300, 4) where the values represent the coordinates of the bounding boxes in the format [x1, y1, x2, y2] - -The model does not need NMS (non-maximum suppression) because the output is already a set of bounding boxes with associated class probabilities and has been trained to avoid overlaps. - -After the post-processing, the output is a the output is a [Focoos Detections](https://github.com/FocoosAI/focoos/blob/4a317a269cb7758ea71b255faeba654d21182083/focoos/ports.py#L179) object containing the predicted bounding boxes with confidence greather than a specific threshold (0.5 by default). - - -## Classes -The model is pretrained on the [COCO dataset](https://cocodataset.org/#home) with 80 classes. - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Class IDClass NameAP
1person53.3
2bicycle28.0
3car40.5
4motorcycle42.6
5airplane67.8
6bus65.0
7train63.7
8truck34.7
9boat27.4
10traffic light25.0
11fire hydrant63.9
12stop sign62.1
13parking meter46.6
14bench23.1
15bird35.0
16cat70.6
17dog65.8
18horse54.2
19sheep52.7
20cow56.5
21elephant64.0
22bear72.9
23zebra69.7
24giraffe68.1
25backpack12.1
26umbrella37.1
27handbag11.9
28tie31.3
29suitcase40.2
30frisbee66.2
31skis22.4
32snowboard27.6
33sports ball42.7
34kite44.9
35baseball bat24.8
36baseball glove33.4
37skateboard49.1
38surfboard34.9
39tennis racket43.8
40bottle34.3
41wine glass30.7
42cup38.6
43fork32.2
44knife15.4
45spoon15.1
46bowl38.1
47banana26.0
48apple18.8
49sandwich36.6
50orange30.6
51broccoli23.6
52carrot22.2
53hot dog31.9
54pizza53.9
55donut45.7
56cake34.7
57chair26.0
58couch44.1
59potted plant24.5
60bed46.2
61dining table28.7
62toilet60.6
63tv56.0
64laptop58.3
65mouse58.4
66remote27.6
67keyboard51.6
68cell phone32.6
69microwave56.1
70oven34.4
71toaster45.6
72sink35.6
73refrigerator53.8
74book12.6
75clock48.9
76vase33.9
77scissors26.9
78teddy bear45.1
79hair drier10.0
80toothbrush26.3
- -
- - -## What are you waiting? Try it! -```python -from focoos import Focoos -import os - -# Initialize the Focoos client with your API key -focoos = Focoos(api_key=os.getenv("FOCOOS_API_KEY")) - -# Get the remote model (fai-rtdetr-n-coco) from Focoos API -model = focoos.get_remote_model("fai-rtdetr-n-coco") - -# Run inference on an image -predictions = model.infer("./image.jpg", threshold=0.5) - -# Output the predictions -print(predictions) -``` diff --git a/docs/models/fai-rtdetr-s-coco.md b/docs/models/fai-rtdetr-s-coco.md deleted file mode 100644 index 807271a5..00000000 --- a/docs/models/fai-rtdetr-s-coco.md +++ /dev/null @@ -1,507 +0,0 @@ -# fai-rtdetr-s-coco - -## Overview -The models is a [RT-DETR](https://github.com/lyuwenyu/RT-DETR) model otimized by [FocoosAI](https://focoos.ai) for the [COCO dataset](https://cocodataset.org/#home). It is a object detection model able to detect 80 thing (dog, cat, car, etc.) classes. - - -## Benchmark -![Benchmark Comparison](./fai-coco.png) -Note: FPS are computed on NVIDIA T4 using TensorRT and image size 640x640. - -## Model Details -The model is based on the [RT-DETR](https://github.com/lyuwenyu/RT-DETR) architecture. It is a object detection model that uses a transformer-based encoder-decoder architecture. - -### Neural Network Architecture -The [RT-DETR](https://github.com/lyuwenyu/RT-DETR) FocoosAI implementation optimize the original neural network architecture for improving the model's efficiency and performance. The original model is fully described in this [paper](https://arxiv.org/abs/2304.08069). - -RT-DETR is a hybrid model that uses three main components: a *backbone* for extracting features, an *encoder* for upscaling the features, and a *transformer-based decoder* for generating the detection output. - -![alt text](./rt-detr.png) - -In this implementation: - -- the backbone is [STDC-2](https://github.com/MichaelFan01/STDC-Seg) that show an amazing trade-off between performance and efficiency. -- the encoder is a bi-FPN (bilinear feature pyramid network). With respect to the original paper, we removed the attention modules in the encoder and we reduce the internal features dimension, speeding up the inference while only marginally affecting the accuracy. -- the transformer decoder is a lighter version of the original, having only 3 decoder layers, instead of 6, and we select 300 queries. - -### Losses -We use the same losses as the original paper: - -- loss_vfl: a variant of the binary cross entropy loss for the classification of the classes that is weighted by the correctness of the predicted bounding boxes IoU. -- loss_bbox: an L1 loss computing the distance between the predicted bounding boxes and the ground truth bounding boxes. -- loss_giou: a loss minimizing the IoU the predicted bounding boxes and the ground truth bounding boxes. for more details look here: [GIoU](https://giou.stanford.edu/). - -These losses are applied to each output of the transformer decoder, meaning that we apply it on the output and on each auxiliary output of the transformer decoder layers. -Please refer to the [RT-DETR paper](https://arxiv.org/abs/2304.08069) for more details. - -### Output Format -The pre-processed output of the model is set of bounding boxes with associated class probabilities. In particular, the output is composed by three tensors: - -- class_ids: a tensor of 300 elements containing the class id associated with each bounding box (such as 1 for wall, 2 for building, etc.) -- scores: a tensor of 300 elements containing the corresponding probability of the class_id -- boxes: a tensor of shape (300, 4) where the values represent the coordinates of the bounding boxes in the format [x1, y1, x2, y2] - -The model does not need NMS (non-maximum suppression) because the output is already a set of bounding boxes with associated class probabilities and has been trained to avoid overlaps. - -After the post-processing, the output is a the output is a [Focoos Detections](https://github.com/FocoosAI/focoos/blob/4a317a269cb7758ea71b255faeba654d21182083/focoos/ports.py#L179) object containing the predicted bounding boxes with confidence greather than a specific threshold (0.5 by default). - - -## Classes -The model is pretrained on the [COCO dataset](https://cocodataset.org/#home) with 80 classes. - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Class IDClass NameAP
1person54.7
2bicycle29.1
3car41.4
4motorcycle44.9
5airplane71.4
6bus67.8
7train68.9
8truck36.4
9boat26.8
10traffic light25.0
11fire hydrant66.0
12stop sign62.2
13parking meter46.1
14bench25.2
15bird36.5
16cat72.6
17dog68.5
18horse57.9
19sheep54.1
20cow56.6
21elephant66.2
22bear78.3
23zebra70.0
24giraffe70.0
25backpack14.9
26umbrella39.9
27handbag13.2
28tie32.6
29suitcase41.2
30frisbee66.3
31skis24.9
32snowboard31.6
33sports ball44.8
34kite45.1
35baseball bat29.7
36baseball glove35.2
37skateboard54.5
38surfboard39.9
39tennis racket46.1
40bottle35.8
41wine glass32.6
42cup41.1
43fork35.5
44knife18.9
45spoon18.0
46bowl42.2
47banana24.6
48apple18.6
49sandwich41.6
50orange33.1
51broccoli22.4
52carrot22.2
53hot dog37.6
54pizza55.2
55donut48.0
56cake36.7
57chair28.4
58couch47.8
59potted plant26.8
60bed49.0
61dining table30.5
62toilet60.1
63tv57.2
64laptop59.6
65mouse62.3
66remote27.7
67keyboard53.8
68cell phone33.2
69microwave60.7
70oven38.8
71toaster41.9
72sink37.0
73refrigerator57.6
74book13.8
75clock50.3
76vase35.5
77scissors31.8
78teddy bear44.7
79hair drier10.3
80toothbrush26.8
- -
- - -## What are you waiting? Try it! -```python -from focoos import Focoos -import os - -# Initialize the Focoos client with your API key -focoos = Focoos(api_key=os.getenv("FOCOOS_API_KEY")) - -# Get the remote model (fai-rtdetr-s-coco) from Focoos API -model = focoos.get_remote_model("fai-rtdetr-s-coco") - -# Run inference on an image -predictions = model.infer("./image.jpg", threshold=0.5) - -# Output the predictions -print(predictions) -``` diff --git a/docs/models/fai_cls.md b/docs/models/fai_cls.md new file mode 100644 index 00000000..44638ed6 --- /dev/null +++ b/docs/models/fai_cls.md @@ -0,0 +1,40 @@ + +## Overview +The models is a [Mask2Former](https://github.com/facebookresearch/Mask2Former) model otimized by [FocoosAI](https://focoos.ai) for the [ADE20K dataset](https://groups.csail.mit.edu/vision/datasets/ADE20K/). It is a semantic segmentation model able to segment 150 classes, comprising both stuff (sky, road, etc.) and thing (dog, cat, car, etc.). + +## Model Details +The model is based on the [Mask2Former](https://github.com/facebookresearch/Mask2Former) architecture. It is a segmentation model that uses a transformer-based encoder-decoder architecture. +Differently from traditional segmentation models (such as [DeepLab](https://arxiv.org/abs/1802.02611)), Mask2Former uses a mask-classification approach, where the prediction is made by a set of segmentation mask with associated class probabilities. + +### Neural Network Architecture +The [Mask2Former](https://arxiv.org/abs/2112.01527) FocoosAI implementation optimize the original neural network architecture for improving the model's efficiency and performance. The original model is fully described in this [paper](https://arxiv.org/abs/2112.01527). + +Mask2Former is a hybrid model that uses three main components: a *backbone* for extracting features, a *pixel decoder* for upscaling the features, and a *transformer-based decoder* for generating the segmentation output. + +![alt text](./mask2former.png) + +In this implementation: + + - the backbone is [STDC-1](https://github.com/MichaelFan01/STDC-Seg) that shows a trade-off tending to be more efficient. + - the pixel decoder is a [FPN](https://arxiv.org/abs/1612.03144) getting the features from the stage 2 (1/4 resolution), 3 (1/8 resolution), 4 (1/16 resolution) and 5 (1/32 resolution) of the backbone. Differently from the original paper, for the sake of portability, we removed the deformable attention modules in the pixel decoder, speeding up the inference while only marginally affecting the accuracy. + - the transformer decoder is a extremely light version of the original, having only 1 decoder layer (instead of 9) and 100 learnable queries. + +### Losses +We use the same losses as the original paper: + +- loss_ce: Cross-entropy loss for the classification of the classes +- loss_dice: Dice loss for the segmentation of the classes +- loss_mask: A binary cross-entropy loss applied to the predicted segmentation masks + +Please refer to the [Mask2Former paper](https://arxiv.org/abs/2112.01527) for more details. + +### Output Format +The pre-processed output of the model is set of masks with associated class probabilities. In particular, the output is composed by three tensors: + +- class_ids: a tensor of 100 elements containing the class id associated with each mask (such as 1 for wall, 2 for building, etc.) +- scores: a tensor of 100 elements containing the corresponding probability of the class_id +- masks: a tensor of shape (100, H, W) where H and W are the height and width of the input image and the values represent the index of the class_id associated with the pixel + +The model does not need NMS (non-maximum suppression) because the output is already a set of masks with associated class probabilities and has been trained to avoid overlapping masks. + +After the post-processing, the output is a [Focoos Detections](https://github.com/FocoosAI/focoos/blob/4a317a269cb7758ea71b255faeba654d21182083/focoos/ports.py#L179) object containing the predicted masks with confidence greather than a specific threshold (0.5 by default). diff --git a/docs/models/fai_detr.md b/docs/models/fai_detr.md new file mode 100644 index 00000000..7f846951 --- /dev/null +++ b/docs/models/fai_detr.md @@ -0,0 +1,41 @@ + +## Overview +The models is the reimplementation of the [RT-DETR](https://github.com/lyuwenyu/RT-DETR) model by [FocoosAI](https://focoos.ai) for the [COCO dataset](https://cocodataset.org/#home). It is a object detection model able to detect 80 thing (dog, cat, car, etc.) classes. + + +## Model Details +The model is based on the [RT-DETR](https://github.com/lyuwenyu/RT-DETR) architecture. It is a object detection model that uses a transformer-based encoder-decoder architecture. + +### Neural Network Architecture +This implementation is a reimplementation of the [RT-DETR](https://github.com/lyuwenyu/RT-DETR) model by [FocoosAI](https://focoos.ai). The original model is fully described in this [paper](https://arxiv.org/abs/2304.08069). + +RT-DETR is a hybrid model that uses three main components: a *backbone* for extracting features, an *encoder* for upscaling the features, and a *transformer-based decoder* for generating the detection output. + +![alt text](./rt-detr.png) + +In this implementation: + +- the backbone is a [Resnet-50](https://github.com/pytorch/vision/blob/main/torchvision/models/resnet.py),that guarantees a good performance while having good efficiency. +- the encoder is the Hybrid Encoder, as proposed by the paper, and it is a bi-FPN (bilinear feature pyramid network) that includes a transformer encoder on the smaller feature resolution for improving efficiency. +- The query selection mechanism select the features of the pixels (aka queries) with the highest probability of containing an object and pass them to a transformer decoder head that will generate the final detection output. In this implementation, we select 300 queries and use 6 transformer decoder layers. + +### Losses +We use the same losses as the original paper: + +- loss_vfl: a variant of the binary cross entropy loss for the classification of the classes that is weighted by the correctness of the predicted bounding boxes IoU. +- loss_bbox: an L1 loss computing the distance between the predicted bounding boxes and the ground truth bounding boxes. +- loss_giou: a loss minimizing the IoU the predicted bounding boxes and the ground truth bounding boxes. For more details look at [GIoU](https://giou.stanford.edu/). + +These losses are applied to each output of the transformer decoder, meaning that we apply it on the output and on each auxiliary output of the transformer decoder layers. +Please refer to the [RT-DETR paper](https://arxiv.org/abs/2304.08069) for more details. + +### Output Format +The pre-processed output of the model is set of bounding boxes with associated class probabilities. In particular, the output is composed by three tensors: + +- class_ids: a tensor of 300 elements containing the class id associated with each bounding box (such as 1 for wall, 2 for building, etc.) +- scores: a tensor of 300 elements containing the corresponding probability of the class_id +- boxes: a tensor of shape (300, 4) where the values represent the coordinates of the bounding boxes in the format [x1, y1, x2, y2] + +The model does not need NMS (non-maximum suppression) because the output is already a set of bounding boxes with associated class probabilities and has been trained to avoid overlaps. + +After the post-processing, the output is a the output is a [Focoos Detections](https://github.com/FocoosAI/focoos/blob/4a317a269cb7758ea71b255faeba654d21182083/focoos/ports.py#L179) object containing the predicted bounding boxes with confidence greather than a specific threshold (0.5 by default). diff --git a/docs/models/fai_mf.md b/docs/models/fai_mf.md new file mode 100644 index 00000000..772e4cdd --- /dev/null +++ b/docs/models/fai_mf.md @@ -0,0 +1,111 @@ +# FAI-MF (FocoosAI MaskFormer) + +## Overview + +The FAI-MF model is a [Mask2Former](https://github.com/facebookresearch/Mask2Former) implementation optimized by [FocoosAI](https://focoos.ai) for semantic and instance segmentation tasks. +Unlike traditional segmentation models such as [DeepLab](https://arxiv.org/abs/1802.02611), Mask2Former employs a mask-classification approach where predictions consist of segmentation masks paired with class probabilities. + +## Neural Network Architecture + +The FAI-MF model is built on the [Mask2Former](https://arxiv.org/abs/2112.01527) architecture, featuring a transformer-based encoder-decoder design with three main components: + +![Mask2Former Architecture](./mask2former.png) + +### Backbone + - **Network**: Any backbone that can extract multi-scale features from an image + - **Output**: Multi-scale features from stages 2-5 at resolutions 1/4, 1/8, 1/16, and 1/32 + +### Pixel Decoder + - **Architecture**: Feature Pyramid Network (FPN) + - **Input**: Features from backbone stages 2-5 + - **Modifications**: Deformable attention modules removed for improved portability and inference speed + - **Output**: Upscaled multi-scale features for mask generation + +### Transformer Decoder + - **Design**: Lightweight version of the original Mask2Former decoder + - **Layers**: N decoder layer (depending on the speed/accuracy trade-off) + - **Queries**: Q learnable object queries (usually 100) + - **Components**: + - Self-attention layers + - Masked cross-attention layers + - Feed-forward networks (FFN) + +## Configuration Parameters + +### Core Model Parameters +- `num_classes` (int): Number of segmentation classes +- `num_queries` (int, default=100): Number of learnable object queries +- `resolution` (int, default=640): Input image resolution + +### Backbone Configuration +- `backbone_config` (BackboneConfig): Backbone network configuration + +### Architecture Dimensions +- `pixel_decoder_out_dim` (int, default=256): Pixel decoder output channels +- `pixel_decoder_feat_dim` (int, default=256): Pixel decoder feature channels +- `transformer_predictor_hidden_dim` (int, default=256): Transformer hidden dimension +- `transformer_predictor_dec_layers` (int, default=6): Number of decoder layers +- `head_out_dim` (int, default=256): Prediction head output dimension + +### Inference Configuration +- `postprocessing_type` (str): Either "semantic" or "instance" segmentation +- `mask_threshold` (float, default=0.5): Binary mask threshold +- `threshold` (float, default=0.5): Confidence threshold for the classification scores +- `predict_all_pixels` (bool, default=False): Predict class for every pixel, this is usually better for semantic segmentation + +## Supported Tasks + +### Semantic Segmentation +- **Output**: Dense pixel-wise class predictions +- **Use case**: Scene understanding, medical imaging, autonomous driving +- **Configuration**: Set `postprocessing_type="semantic"` + +### Instance Segmentation +- **Output**: Individual object instances with masks and bounding boxes +- **Use case**: Object detection and counting, robotics, surveillance +- **Configuration**: Set `postprocessing_type="instance"` + +## Model Outputs + +### Inner Model Output (`MaskFormerModelOutput`) +- `masks` (torch.Tensor): Shape [B, num_queries, H, W] - Query mask predictions +- `logits` (torch.Tensor): Shape [B, num_queries, num_classes] - Class predictions +- `loss` (Optional[dict]): Training losses including: + - `loss_ce`: Cross-entropy classification loss + - `loss_mask`: Binary cross-entropy mask loss + - `loss_dice`: Dice coefficient loss + +### Inference Output (`FocoosDetections`) +For each detected object: + +- `bbox` (List[float]): Bounding box coordinates [x1, y1, x2, y2] +- `conf` (float): Confidence score +- `cls_id` (int): Class identifier +- `mask` (str): Base64-encoded binary mask +- `label` (Optional[str]): Human-readable class name + +## Loss Functions + +The model employs three complementary loss functions as described in the [original paper](https://arxiv.org/abs/2112.01527): + +1. **Cross-entropy Loss (`loss_ce`)**: Classification of object classes +2. **Dice Loss (`loss_dice`)**: Shape-aware segmentation loss +3. **Mask Loss (`loss_mask`)**: Binary cross-entropy on predicted masks + +## Available Models +Currently, you can find 5 fai-mf models on the Focoos Hub, 2 for semantic segmentation and 3 for instance-segmentation. + +### Semantic Segmentation Models + +| Model Name | Architecture | Dataset | Metric | FPS Nvidia-T4 | +|------------|--------------|----------|---------|--------------| +| fai-mf-l-ade | Mask2Former (Resnet-101) | ADE20K | mIoU: 48.27
mAcc: 62.15 | 73 | +| fai-mf-m-ade | Mask2Former (STDC-2) | ADE20K | mIoU: 45.32
mACC: 57.75 | 127 | + +### Instance Segmentation Models + +| Model Name | Architecture | Dataset | Metric | FPS Nvidia-T4 | +|------------|--------------|----------|---------|--------------| +| fai-m2f-s-coco-ins | Mask2Former (Resnet-50) | COCO | segm/AP: 41.45
segm/AP50: 64.12 | 86 | +| fai-m2f-m-coco-ins | Mask2Former (Resnet-101) | COCO | segm/AP: 43.09
segm/AP50: 65.87 | 70 | +| fai-m2f-l-coco-ins | Mask2Former (Resnet-101) | COCO | segm/AP: 44.23
segm/AP50: 67.53 | 55 | diff --git a/docs/models/models.md b/docs/models/models.md new file mode 100644 index 00000000..f86f55f8 --- /dev/null +++ b/docs/models/models.md @@ -0,0 +1,42 @@ +# Focoos Models ๐Ÿง  + +With the Focoos SDK, you can take advantage of a collection of foundational models that are optimized for a range of computer vision tasks. These pre-trained models, covering detection and semantic segmentation across various domains, provide an excellent starting point for your specific use case. Whether you need to fine-tune for custom requirements or adapt them to your application, these models offer a solid foundation to accelerate your development process. + +--- + +## Semantic Segmentation ๐Ÿ–ผ๏ธ + +| Model Name | Architecture | Domain (Classes) | Dataset | Metric | FPS Nvidia-T4 | +|------------|--------------|------------------|----------|---------|--------------| +| [fai-mf-l-ade](models/fai-mf-l-ade.md) | [Mask2Former](https://github.com/facebookresearch/Mask2Former) ([Resnet-101](https://github.com/pytorch/vision/blob/main/torchvision/models/resnet.py)) | Common Scene (150) | [ADE20K](https://groups.csail.mit.edu/vision/datasets/ADE20K/) | mIoU: 48.27
mAcc: 62.15 | 73 | +| [fai-mf-m-ade](models/fai-mf-m-ade.md) | [Mask2Former](https://github.com/facebookresearch/Mask2Former) ([STDC-2](https://github.com/MichaelFan01/STDC-Seg)) | Common Scene (150) | [ADE20K](https://groups.csail.mit.edu/vision/datasets/ADE20K/) | mIoU: 45.32
mACC: 57.75 | 127 | +| [fai-mf-s-ade](models/fai-mf-s-ade.md) | [Mask2Former](https://github.com/facebookresearch/Mask2Former) ([STDC-1](https://github.com/MichaelFan01/STDC-Seg)) | Common Scene (150) | [ADE20K](https://groups.csail.mit.edu/vision/datasets/ADE20K/) | mIoU: 41.23
mAcc: 52.21 | 189 | + + mIoU = Intersection over Union averaged by class
+ mAcc = Pixel Accuracy averaged by class
+ FPS = Frames per second computed using TensorRT with resolution 640x640
+ + +## Object Detection ๐Ÿ•ต๏ธโ€โ™‚๏ธ + +| Model Name | Architecture | Domain (Classes) | Dataset | Metric | FPS Nvidia-T4 | +|------------|--------------|------------------|----------|---------|--------------| +| [fai-detr-l-coco](models/fai-detr-l-coco.md) | [RT-DETR](https://github.com/lyuwenyu/RT-DETR) ([Resnet-50](https://github.com/pytorch/vision/blob/main/torchvision/models/resnet.py)) | Common Objects (80) | [COCO](https://cocodataset.org/#home) | bbox/AP: 53.06
bbox/AP50: 70.91 | 87 | +| [fai-detr-m-coco](models/fai-detr-m-coco.md) | [RT-DETR](https://github.com/lyuwenyu/RT-DETR) ([STDC-2](https://github.com/MichaelFan01/STDC-Seg)) | Common Objects (80) | [COCO](https://cocodataset.org/#home) | bbox/AP: 44.69
bbox/AP50: 61.63 | 181 | +| [fai-detr-l-obj365](models/fai-detr-l-obj365.md) | [RT-DETR](https://github.com/lyuwenyu/RT-DETR) ([Resnet50](https://github.com/pytorch/vision/blob/main/torchvision/models/resnet.py)) | Common Objects (365) | [Objects365](https://www.objects365.org/overview.html) | bbox/AP: 34.60
bbox/AP50: 45.81 | 87 | + + AP = Average Precision averaged by class
+ AP50 = Average Precision at IoU threshold 0.50 averaged by class
+ FPS = Frames per second computed using TensorRT with resolution 640x640
+ +## Instance Segmentation ๐ŸŽญ + +| Model Name | Architecture | Domain (Classes) | Dataset | Metric | FPS Nvidia-T4 | +|------------|--------------|------------------|----------|---------|--------------| +| [fai-m2f-s-coco-ins](models/fai-m2f-s-coco-ins.md) | [Mask2Former](https://github.com/facebookresearch/Mask2Former) ([Resnet-50](https://github.com/pytorch/vision/blob/main/torchvision/models/resnet.py)) | Common Objects (80) | [COCO](https://cocodataset.org/#home) | segm/AP: 41.45
segm/AP50: 64.12 | 86 | +| [fai-m2f-m-coco-ins](models/fai-m2f-m-coco-ins.md) | [Mask2Former](https://github.com/facebookresearch/Mask2Former) ([Resnet-101](https://github.com/pytorch/vision/blob/main/torchvision/models/resnet.py)) | Common Objects (80) | [COCO](https://cocodataset.org/#home) | segm/AP: 43.09
segm/AP50: 65.87 | 70 | +| [fai-m2f-l-coco-ins](models/fai-m2f-l-coco-ins.md) | [Mask2Former](https://github.com/facebookresearch/Mask2Former) ([Resnet-101](https://github.com/pytorch/vision/blob/main/torchvision/models/resnet.py)) | Common Objects (80) | [COCO](https://cocodataset.org/#home) | segm/AP: 44.23
segm/AP50: 67.53 | 55 | + + AP = Average Precision averaged by class
+ AP50 = Average Precision at IoU threshold 0.50 averaged by class
+ FPS = Frames per second computed using TensorRT with resolution 640x640
diff --git a/docs/models/plot.py b/docs/models/plot.py index 0e9a392d..a6affabd 100644 --- a/docs/models/plot.py +++ b/docs/models/plot.py @@ -40,7 +40,7 @@ ), ) -# Highlight our model (fai-m2f-s-ade) +# Highlight our model (fai-mf-s-ade) our_models = data[data["Model"].str.startswith("fai")] for i, our_model in our_models.iterrows(): plt.scatter( diff --git a/docs/setup.md b/docs/setup.md index 97ea4d49..3befdbba 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -1,184 +1,101 @@ # Python SDK Setup ๐Ÿ -Focoos models support multiple inference runtimes. -To keep the library lightweight and to allow users to use their environment, optional dependencies (e.g., torch, onnxruntime, tensorrt) are not installed by default. +## Install the Focoos SDK +The Focoos SDK can be installed with different package managers using python 3.10 and above. -Focoos is shipped with the following local inference runtimes that requires to install additional dependencies. If you intend to use only Focoos AI servers for inference, you don't need to install any of the following dependencies. -| RuntimeType | Extra | Runtime | Compatible Devices | Available ExecutionProvider | -|------------|-------|---------|-------------------|---------------------------| -| ONNX_CUDA32 | `[cuda]` | onnxruntime CUDA | NVIDIA GPUs | CUDAExecutionProvider | -| ONNX_TRT32 | `[tensorrt]` | onnxruntime TRT | NVIDIA GPUs (Optimized) | CUDAExecutionProvider, TensorrtExecutionProvider | -| ONNX_TRT16 | `[tensorrt]` | onnxruntime TRT | NVIDIA GPUs (Optimized) | CUDAExecutionProvider, TensorrtExecutionProvider | -| ONNX_CPU | `[cpu]` | onnxruntime CPU | CPU (x86, ARM), M1, M2, M3 (Apple Silicon) | CPUExecutionProvider, CoreMLExecutionProvider, AzureExecutionProvider | -| ONNX_COREML | `[cpu]` | onnxruntime CPU | M1, M2, M3 (Apple Silicon) | CoreMLExecutionProvider, CPUExecutionProvider | -| TORCHSCRIPT_32 | `[torch]` | torchscript | CPU, NVIDIA GPUs | - | +We recommend using [UV](https://docs.astral.sh/uv/) (how to [install uv](https://docs.astral.sh/uv/getting-started/installation/)) as a package manager and environment manager for a streamlined dependency management experience. +however the installation process is the same if you use **pip** or **conda**. +You can easily create a new virtual environment with UV using the following command: +```bash linenums="0" +uv venv --python 3.12 +source .venv/bin/activate +``` -## Install the Focoos SDK -The Focoos SDK can be installed with different package managers using python 3.10 and above. +=== "Default" + + The default installation provides compatibility with both CPU and GPU environments, utilizing PyTorch as the default runtime. + you can perfom training and inference with PyTorch + + ```bash linenums="0" + uv pip install 'focoos @ git+https://github.com/FocoosAI/focoos' + ``` -=== "uv" - We recommend using [UV](https://docs.astral.sh/uv/) (how to [install uv](https://docs.astral.sh/uv/getting-started/installation/)) as a package manager and environment manager for a streamlined dependency management experience. +=== "ONNX Runtime (CUDA) " + To perform inference using ONNX Runtime with GPU (CUDA) acceleration + **Additional requirements:** + Ensure that you have CUDA 12 and cuDNN 9 installed, as they are required for onnxruntime version 1.22.0. + To install cuDNN 9: - You can easily create a new virtual environment with UV using the following command: ```bash linenums="0" - uv venv --python 3.12 - source .venv/bin/activate + apt-get -y install cudnn9-cuda-12 ``` - === "Cloud Runtime" - ```bash linenums="0" - uv pip install 'focoos @ git+https://github.com/FocoosAI/focoos.git' - ``` - - === "CPU ONNX Runtime" - ```bash linenums="0" - uv pip install 'focoos[cpu] @ git+https://github.com/FocoosAI/focoos.git' - ``` - - === "Torchscript Runtime" - To run the models using the torchscript runtime, you need to install the torch package. - ```bash linenums="0" - uv pip install 'focoos[torch] @ git+https://github.com/FocoosAI/focoos.git' - ``` - - === "NVIDIA GPU ONNX Runtime" - **Additional requirements:** - Ensure that you have CUDA 12 and cuDNN 9 installed, as they are required for onnxruntime version 1.20.1. - To install cuDNN 9: - ```bash linenums="0" - apt-get -y install cudnn9-cuda-12 - ``` - - ```bash linenums="0" - uv pip install 'focoos[cuda] @ git+https://github.com/FocoosAI/focoos.git' - ``` - - === "NVIDIA GPU ONNX Runtime with TensorRT" - **Additional requirements:** - Ensure that you have CUDA 12 and cuDNN 9 installed, as they are required for onnxruntime version 1.20.1. - To install cuDNN 9: - ```bash linenums="0" - apt-get -y install cudnn9-cuda-12 - ``` - To perform inference using TensorRT, ensure you have TensorRT version 10.5 installed. - ```bash linenums="0" - uv pip install 'focoos[tensorrt] @ git+https://github.com/FocoosAI/focoos.git' - ``` - -=== "pip" - Create and activate a new virtual environment using pip with the following commands: ```bash linenums="0" - python -m venv .venv - source .venv/bin/activate + uv pip install 'focoos[onnx] @ git+https://github.com/FocoosAI/focoos' ``` - === "Cloud Runtime" - ```bash linenums="0" - pip install 'focoos @ git+https://github.com/FocoosAI/focoos.git' - ``` - - === "CPU ONNX Runtime" - ```bash linenums="0" - pip install 'focoos[cpu] @ git+https://github.com/FocoosAI/focoos.git' - ``` - - === "Torchscript Runtime" - ```bash linenums="0" - pip install 'focoos[torch] @ git+https://github.com/FocoosAI/focoos.git' - ``` - - === "NVIDIA GPU ONNX Runtime" - **Additional requirements:** - Ensure that you have CUDA 12 and cuDNN 9 installed, as they are required for onnxruntime version 1.20.1. - To install cuDNN 9: - ```bash linenums="0" - apt-get -y install cudnn9-cuda-12 - ``` - ```bash linenums="0" - pip install 'focoos[cuda] @ git+https://github.com/FocoosAI/focoos.git' - ``` - - === "NVIDIA GPU ONNX Runtime with TensorRT" - **Additional requirements:** - Ensure that you have CUDA 12 and cuDNN 9 installed, as they are required for onnxruntime version 1.20.1. - To install cuDNN 9: - ```bash linenums="0" - apt-get -y install cudnn9-cuda-12 - ``` - To perform inference using TensorRT, ensure you have TensorRT version 10.5 installed. - ```bash linenums="0" - pip install 'focoos[tensorrt] @ git+https://github.com/FocoosAI/focoos.git' - ``` - -=== "conda" - Create and activate a new [conda](https://docs.conda.io/en/latest/) (how to [install conda](https://docs.conda.io/projects/conda/en/latest/user-guide/install/index.html)) environment with Python 3.10 or higher: + +=== "ONNX Runtime (Tensorrt) " + To perform inference using ONNX Runtime with GPU (Tensorrt) acceleration + **Additional requirements:** + Ensure that you have CUDA 12 and cuDNN 9 installed, as they are required for onnxruntime version 1.22.0. + To install cuDNN 9: + ```bash linenums="0" - conda create -n focoos python=3.12 - conda activate focoos - conda install pip + apt-get -y install cudnn9-cuda-12 ``` - === "Cloud Runtime" - ```bash linenums="0" - pip install 'focoos @ git+https://github.com/FocoosAI/focoos.git' - ``` - - === "CPU ONNX Runtime" - ```bash linenums="0" - pip install 'focoos[cpu] @ git+https://github.com/FocoosAI/focoos.git' - ``` - - === "Torchscript Runtime" - ```bash linenums="0" - pip install 'focoos[torch] @ git+https://github.com/FocoosAI/focoos.git' - ``` - - === "NVIDIA GPU ONNX Runtime" - **Additional requirements:** - Ensure that you have CUDA 12 and cuDNN 9 installed, as they are required for onnxruntime version 1.20.1. - To install cuDNN 9: - ```bash linenums="0" - apt-get -y install cudnn9-cuda-12 - ``` - ```bash linenums="0" - pip install 'focoos[cuda] @ git+https://github.com/FocoosAI/focoos.git' - ``` - - === "NVIDIA GPU ONNX Runtime with TensorRT" - **Additional requirements:** - Ensure that you have CUDA 12 and cuDNN 9 installed, as they are required for onnxruntime version 1.20.1. - To install cuDNN 9: - ```bash linenums="0" - apt-get -y install cudnn9-cuda-12 - ``` - To perform inference using TensorRT, ensure you have TensorRT version 10.5 installed. - ```bash linenums="0" - pip install 'focoos[tensorrt] @ git+https://github.com/FocoosAI/focoos.git' - ``` + To perform inference using TensorRT, ensure you have TensorRT version 10.5 installed. + + ```bash linenums="0" + uv pip install 'focoos[onnx,tensorrt] @ git+https://github.com/FocoosAI/focoos' + ``` + +=== "CPU ONNX Runtime" + + ```bash linenums="0" + uv pip install 'focoos[onnx-cpu] @ git+https://github.com/FocoosAI/focoos' + ``` !!! note - ๐Ÿค– **Multiple Runtimes:** You can install multiple extras by running `pip install .[torch,cuda,tensorrt]`. Anyway you can't use `cpu` and `cuda` or `tensorrt` at the same time. + ๐Ÿค– **Multiple Runtimes:** You can install multiple extras by running `pip install 'focoos[onnx,tensorrt] @ git+https://github.com/FocoosAI/focoos.git'`. Note that you can't use `onnx-cpu` and `onnx` or `tensorrt` at the same time. !!! note - ๐Ÿ› ๏ธ **Installation Tip:** If you want to install a specific version, for example `v0.1.3`, use: + ๐Ÿ› ๏ธ **Installation Tip:** If you want to install a specific version, for example `v0.14.1`, use: + ```bash linenums="0" - pip install 'focoos @ git+https://github.com/FocoosAI/focoos.git@v0.1.3' + uv pip install 'focoos @ git+https://github.com/FocoosAI/focoos@v0.14.1' ``` + ๐Ÿ“‹ **Check Versions:** Visit [https://github.com/FocoosAI/focoos/tags](https://github.com/FocoosAI/focoos/tags) for available versions. + +## Inference Runtime support +Focoos models support multiple inference runtimes. The library can be used without any extras for training and inference using the PyTorch runtime. Additional extras are only needed if you want to use ONNX or TensorRT runtimes for optimized inference. + +| RuntimeType | Extra | Runtime | Compatible Devices | Available ExecutionProvider | +|------------|-------|---------|-------------------|---------------------------| +| TORCHSCRIPT_32 | - | torchscript | CPU, NVIDIA GPUs | - | +| ONNX_CUDA32 | `[onnx]` | onnxruntime GPU | NVIDIA GPUs | CUDAExecutionProvider | +| ONNX_TRT32 | `[tensorrt]` | onnxruntime TRT | NVIDIA GPUs (Optimized) | CUDAExecutionProvider, TensorrtExecutionProvider | +| ONNX_TRT16 | `[tensorrt]` | onnxruntime TRT | NVIDIA GPUs (Optimized) | CUDAExecutionProvider, TensorrtExecutionProvider | +| ONNX_CPU | `[onnx-cpu]` | onnxruntime CPU | CPU (x86, ARM), M1, M2, M3 (Apple Silicon) | CPUExecutionProvider, CoreMLExecutionProvider, AzureExecutionProvider | +| ONNX_COREML | `[onnx-cpu]` | onnxruntime CPU | M1, M2, M3 (Apple Silicon) | CoreMLExecutionProvider, CPUExecutionProvider | + + ## Docker and Devcontainers -For container support, Focoos offers four different Docker images: +For container support, Focoos offers different Docker images: +- `focoos-gpu`: Includes ONNX Runtime (CUDA) support +- `focoos-tensorrt`: Includes ONNX and TensorRT support - `focoos-cpu`: only CPU -- `focoos-cuda`: Includes ONNX (CUDA) support -- `focoos-torch`: Includes ONNX and Torchscript (CUDA) support -- `focoos-tensorrt`: Includes ONNX, Torchscript, and TensorRT support to use the docker images, you can run the following command: ```bash linenums="0" -docker run -it . --target=focoos-cpu +docker build -t focoos-gpu . --target=focoos-gpu +docker run -it focoos-gpu ``` This repository also includes a devcontainer configuration for each of the above images. You can launch these devcontainers in Visual Studio Code for a seamless development experience. diff --git a/docs/training.md b/docs/training.md new file mode 100644 index 00000000..c85eea4c --- /dev/null +++ b/docs/training.md @@ -0,0 +1,128 @@ +# How to Train a Computer Vision Model with Focoos + +Focoos provides a comprehensive training framework that makes it easy to train state-of-the-art computer vision models on your own datasets. Whether you're working on object detection, image classification, or other vision tasks, Focoos offers an intuitive training pipeline that handles everything from data preparation to model optimization. + +[![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/FocoosAI/focoos/blob/main/tutorials/training.ipynb) + +Key features of the Focoos training framework include: + +- **Easy Dataset Integration**: Seamlessly import and prepare your datasets using Focoos' data loading utilities +- **Flexible Model Architecture**: Choose from a variety of pre-built model architectures or customize your own +- **Advanced Training Features**: + - Mixed precision training for faster training and reduced memory usage + - Automatic learning rate scheduling + - Early stopping and model checkpointing + - Distributed training support +- **Experiment Tracking**: Monitor training progress, visualize metrics, and compare experiments through the Focoos Hub + +In the following sections, we'll guide you through the process of training a model with Focoos, from setting up your environment to deploying your trained model. + +## ๐ŸŽจ Fine-tune a model in 3 steps + +In this guide, we will perform the following steps: + +1. [๐Ÿ“ฆ Select dataset](#1-select-dataset) +2. [๐Ÿƒโ€โ™‚๏ธ Train model](#2-train-the-model) +3. [๐Ÿงช Test model](#3-test-the-model) + +## 0. \[Optional\] Connect to the Focoos Hub + +Focoos can be used without having an accont on the [Focoos Hub](http://app.focoos.ai). With it, you will unlock additional functionalities, as we will see below. If you have it, just connect to the HUB. +``` +from focoos.hub import FocoosHUB + +FOCOOS_API_KEY = None # write here your API key +hub = FocoosHUB(api_key=FOCOOS_API_KEY) +``` + +## 1. Select dataset + +Before starting the training, we need to get a dataset. You can either use a local dataset or you can download one from the hub. + +### \[Optional\] Download the data from the Hub +If you want to download a dataset from the hub, you can use it to directly store it in your local environment. +Check the reference of your dataset on the platform and use it in the following cell. If you want to try an example dataset, just use one of the many available on the [Focoos Hub](http://app.focoos.ai). + +```python +dataset_ref = "" +dataset = hub.get_remote_dataset(dataset_ref) +print(dataset) + +dataset_path = dataset.download_data() +``` + +### Get the training dataset + +Now that we downloaded the dataset, we can magically ๐Ÿช„ instanciate the dataset using the [`AutoDataset`](focoos/api/auto_dataset/#focoos.data.auto_dataset.AutoDataset) as will be used in the training. You can optionally specify aumgentations for the training using the [`DatasetAugmentation`](focoos/api/auto_dataset/#focoos.data.default_aug.DatasetAugmentations) dataclass. + +```python +from focoos.data.auto_dataset import AutoDataset +from focoos.data.default_aug import DatasetAugmentations +from focoos.ports import DatasetSplitType + +task = dataset.task # see ports.Task for more information +layout = dataset.layout # see ports.DatasetLayout for more information +auto_dataset = AutoDataset(dataset_name=dataset_path, task=task, layout=layout) + +augs = DatasetAugmentations(resolution=512).get_augmentations() + +train_dataset = auto_dataset.get_split(augs=augs, split=DatasetSplitType.TRAIN) +valid_dataset = auto_dataset.get_split(augs=augs, split=DatasetSplitType.VAL) +``` + +## 2. Train the Model + +### Instanciate a model +The first step to personalize your model is to instance a model. You can get a model using the ModelManager by specifying a model name. Optionally, you can also get one of your trained models on the hub. If you want to follow the example, just use `fai-detr-m-coco` as the model reference. + +```python +from focoos.model_manager import ModelManager + +model_ref = "" +model = ModelManager.get("hub://" + model_ref, hub=hub) +``` + +### Select the hyper-parameters +The next step is to create a [`TrainerArgs`](focoos/api/ports/#focoos.ports.TrainerArgs) with the hyper-parameters such as the learning rate, the number of iterations and so on. +Optionally, if you are using the hub, you can specify `sync_to_hub=True` to track the experiment on the Focoos Hub. + +```python +from focoos.ports import TrainerArgs + +args = TrainerArgs( + run_name="football-tutorial", # the name of the experiment + output_dir="./experiments", # the folder where the model is saved + batch_size=16, # how many images in each iteration + max_iters=500, # how many iterations lasts the training + eval_period=100, # period after we eval the model on the validation (in iterations) + learning_rate=0.0001, # learning rate + weight_decay=0.0001, # regularization strenght (set it properly to avoid under/over fitting) + sync_to_hub=True, # Use this to see the model under training on the platform +) +``` + +### Train the model +Now we are set up. We can directly call the train function of the model. + +```python +model.train(args, train_dataset, valid_dataset, hub=hub) +``` + + +## 3. Test the Model +Now that the model is ready, let's see how it behaves. + +```python +import random +from PIL import Image +from focoos.utils.vision import annotate_image + +index = random.randint(0, len(valid_dataset)) + +ground_truth = valid_dataset.preview(index, use_augmentations=False).save("ground_truth.jpg") + +image = Image.open(valid_dataset[index]["file_name"]) +outputs = model(image) + +prediction = annotate_image(image, outputs, task=task, classes=model.model_info.classes).save("prediction.jpg") +``` diff --git a/focoos/__init__.py b/focoos/__init__.py index 0cec4196..c3ab4f46 100644 --- a/focoos/__init__.py +++ b/focoos/__init__.py @@ -1,32 +1,35 @@ from .config import FOCOOS_CONFIG -from .focoos import Focoos -from .local_model import LocalModel +from .hub import ApiClient, FocoosHUB, RemoteDataset, RemoteModel +from .infer.infer_model import InferModel +from .infer.runtimes.load_runtime import load_runtime +from .model_manager import ConfigManager, ModelManager from .ports import ( DEV_API_URL, LOCAL_API_URL, PROD_API_URL, DatasetLayout, + DatasetMetadata, DatasetPreview, + DetectronDict, FocoosDet, FocoosDetections, - FocoosTask, GPUDevice, GPUInfo, - Hyperparameters, LatencyMetrics, - ModelMetadata, + ModelFamily, + ModelInfo, ModelPreview, ModelStatus, OnnxRuntimeOpts, - RuntimeTypes, + RemoteModelInfo, + RuntimeType, SystemInfo, + Task, + TrainerArgs, TrainingInfo, - TrainInstance, ) -from .remote_model import RemoteModel -from .runtime import ONNXRuntime, load_runtime -from .utils.api_client import ApiClient -from .utils.logger import get_logger +from .processor import ProcessorManager +from .utils.logger import _setup_logging, get_logger from .utils.system import get_cuda_version, get_system_info from .utils.vision import ( base64mask_to_mask, @@ -39,31 +42,30 @@ sv_to_fai_detections, ) +_setup_logging() + __all__ = [ "FOCOOS_CONFIG", - "Focoos", - "LocalModel", + "RemoteModel", + "InferModel", "RemoteModel", "FocoosDetections", "FocoosDet", - "FocoosTask", - "ModelMetadata", + "Task", + "RemoteModelInfo", "ModelStatus", "DatasetLayout", "DatasetPreview", "GPUDevice", "GPUInfo", - "Hyperparameters", "LatencyMetrics", "ModelPreview", "OnnxRuntimeOpts", - "RuntimeTypes", + "RuntimeType", "SystemInfo", "TrainingInfo", - "TrainInstance", "get_system_info", "get_cuda_version", - "ONNXRuntime", "load_runtime", "DEV_API_URL", "LOCAL_API_URL", @@ -78,4 +80,15 @@ "sv_to_fai_detections", "get_logger", "ApiClient", + "RemoteDataset", + "RemoteModel", + "ModelInfo", + "TrainerArgs", + "DatasetMetadata", + "DetectronDict", + "ModelManager", + "ProcessorManager", + "ConfigManager", + "ModelFamily", + "FocoosHUB", ] diff --git a/focoos/config.py b/focoos/config.py index ce0685f9..67e42166 100644 --- a/focoos/config.py +++ b/focoos/config.py @@ -3,13 +3,14 @@ This module defines the configuration settings for the Focoos AI SDK, including API credentials, logging levels, default endpoints, and runtime preferences. +It provides a centralized way to manage SDK behavior through environment variables. Classes: FocoosConfig: Pydantic settings class for Focoos SDK configuration. Constants: LogLevel: Type definition for supported logging levels. - FOCOOS_CONFIG: Global configuration instance. + FOCOOS_CONFIG: Global configuration instance used throughout the SDK. """ import typing @@ -17,7 +18,7 @@ from pydantic_settings import BaseSettings -from focoos.ports import PROD_API_URL, RuntimeTypes +from focoos.ports import PROD_API_URL, RuntimeType LogLevel = typing.Literal["DEBUG", "INFO", "WARNING", "ERROR", "FATAL", "CRITICAL"] @@ -32,15 +33,15 @@ class FocoosConfig(BaseSettings): Attributes: focoos_api_key (Optional[str]): API key for authenticating with Focoos services. - Defaults to None. - focoos_log_level (LogLevel): Logging level for the SDK. + Required for accessing protected API endpoints. Defaults to None. + focoos_log_level (LogLevel): Logging level for the SDK to control verbosity. Defaults to "DEBUG". - default_host_url (str): Default API endpoint URL. + default_host_url (str): Default API endpoint URL for all service requests. Defaults to the production API URL. - runtime_type (RuntimeTypes): Default runtime type for model inference. - Defaults to ONNX_CUDA32 for NVIDIA GPU acceleration. - warmup_iter (int): Number of warmup iterations for model initialization. - Defaults to 2. + runtime_type (RuntimeTypes): Default runtime type for model inference engines. + Defaults to TORCHSCRIPT_32 for optimal performance. + warmup_iter (int): Number of warmup iterations for model initialization to + stabilize performance metrics. Defaults to 2. Example: Setting configuration via environment variables in bash: @@ -60,7 +61,7 @@ class FocoosConfig(BaseSettings): focoos_api_key: Optional[str] = None focoos_log_level: LogLevel = "DEBUG" default_host_url: str = PROD_API_URL - runtime_type: RuntimeTypes = RuntimeTypes.ONNX_CUDA32 + runtime_type: RuntimeType = RuntimeType.TORCHSCRIPT_32 warmup_iter: int = 2 diff --git a/focoos/data/__init__.py b/focoos/data/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/focoos/data/auto_dataset.py b/focoos/data/auto_dataset.py new file mode 100644 index 00000000..818492ce --- /dev/null +++ b/focoos/data/auto_dataset.py @@ -0,0 +1,160 @@ +import os +from typing import List + +from focoos.data.datasets.dict_dataset import DictDataset +from focoos.data.datasets.map_dataset import MapDataset +from focoos.data.mappers.classification_dataset_mapper import ClassificationDatasetMapper +from focoos.data.mappers.detection_dataset_mapper import DetectionDatasetMapper +from focoos.data.mappers.mapper import DatasetMapper +from focoos.data.mappers.semantic_dataset_mapper import SemanticDatasetMapper +from focoos.data.transforms import transform as T +from focoos.ports import ( + DATASETS_DIR, + DatasetLayout, + DatasetSplitType, + Task, +) +from focoos.utils.logger import get_logger +from focoos.utils.system import ( + check_folder_exists, + extract_archive, + is_inside_sagemaker, +) + +logger = get_logger(__name__) + + +class AutoDataset: + def __init__( + self, + dataset_name: str, + task: Task, + layout: DatasetLayout, + datasets_dir: str = DATASETS_DIR, + ): + self.task = task + self.layout = layout + self.datasets_dir = datasets_dir + self.dataset_name = dataset_name + + if self.layout is not DatasetLayout.CATALOG: + dataset_path = os.path.join(self.datasets_dir, dataset_name) + else: + dataset_path = self.datasets_dir + + if dataset_path.endswith(".zip") or dataset_path.endswith(".gz"): + # compressed path: datasets_root_dir/dataset_compressed/{dataset_name}.zip + # _dest_path = os.path.join(self.datasets_root_dir, dataset_name.split(".")[0]) + assert not (self.layout == DatasetLayout.CATALOG and not is_inside_sagemaker()), ( + "Catalog layout does not support compressed datasets externally to Sagemaker." + ) + if self.layout == DatasetLayout.CATALOG: + dataset_path = extract_archive(dataset_path) + logger.info(f"Extracted archive: {dataset_path}, {os.listdir(dataset_path)}") + else: + dataset_name = dataset_name.split(".")[0] + _dest_path = os.path.join(self.datasets_dir, dataset_name) + dataset_path = extract_archive(dataset_path, _dest_path) + logger.info(f"Extracted archive: {dataset_path}, {os.listdir(dataset_path)}") + + self.dataset_path = str(dataset_path) + self.dataset_name = dataset_name + logger.info( + f"โœ… Dataset name: {self.dataset_name}, Dataset Path: {self.dataset_path}, Dataset Layout: {self.layout}" + ) + + def _load_split(self, dataset_name: str, split: DatasetSplitType) -> DictDataset: + if self.layout == DatasetLayout.CATALOG: + return DictDataset.from_catalog(ds_name=dataset_name, split=split, root=self.dataset_path) + else: + ds_root = self.dataset_path + if not check_folder_exists(ds_root): + raise FileNotFoundError(f"Dataset {ds_root} not found") + split_path = self._get_split_path(dataset_root=ds_root, split_type=split) + if self.layout == DatasetLayout.ROBOFLOW_SEG: + return DictDataset.from_roboflow_seg(ds_dir=split_path, task=self.task) + elif self.layout == DatasetLayout.CLS_FOLDER: + return DictDataset.from_folder(root_dir=split_path) + # elif self.layout == DatasetLayout.SUPERVISELY: + # return DictDataset.from_supervisely(ds_dir=split_path, task=self.task) + elif self.layout == DatasetLayout.ROBOFLOW_COCO: + return DictDataset.from_roboflow_coco(ds_dir=split_path, task=self.task) + else: # Focoos + raise NotImplementedError(f"Dataset layout {self.layout} not implemented") + + def _load_mapper( + self, + augs: List[T.Transform], + is_validation_split: bool, + ) -> DatasetMapper: + if self.task == Task.SEMSEG: + return SemanticDatasetMapper( + image_format="RGB", + ignore_label=255, + augmentations=augs, + is_train=not is_validation_split, + ) + elif self.task == Task.DETECTION: + return DetectionDatasetMapper( + image_format="RGB", + is_train=not is_validation_split, + augmentations=augs, + ) + elif self.task == Task.INSTANCE_SEGMENTATION: + return DetectionDatasetMapper( + image_format="RGB", + is_train=not is_validation_split, + augmentations=augs, + use_instance_mask=True, + ) + elif self.task == Task.CLASSIFICATION: + return ClassificationDatasetMapper( + image_format="RGB", + is_train=not is_validation_split, + augmentations=augs, + ) + else: + raise NotImplementedError(f"Task {self.task} not found in autodataset _load_mapper()") + + def _get_split_path(self, dataset_root: str, split_type: DatasetSplitType) -> str: + if split_type == DatasetSplitType.TRAIN: + possible_names = ["train", "training"] + for name in possible_names: + split_path = os.path.join(dataset_root, name) + if check_folder_exists(split_path): + return split_path + raise FileNotFoundError(f"Train split not found in {dataset_root}") + elif split_type == DatasetSplitType.VAL: + possible_names = ["valid", "val", "validation"] + for name in possible_names: + split_path = os.path.join(dataset_root, name) + if check_folder_exists(split_path): + return split_path + raise FileNotFoundError(f"Validation split not found in {dataset_root}") + else: + raise ValueError(f"Invalid split type: {split_type}") + + def get_split( + self, + augs: List[T.Transform], + split: DatasetSplitType = DatasetSplitType.TRAIN, + ) -> MapDataset: + """ + Generate a dataset for a given dataset name with optional augmentations. + + Parameters: + short_edge_length (int): The length of the shorter edge of the images. + max_size (int): The maximum size of the images. + extra_augs (List[Transform]): Extra augmentations to apply. + + Returns: + MapDataset: A DictDataset with DatasetMapper for training. + """ + + return MapDataset( + dataset=self._load_split(dataset_name=self.dataset_name, split=split), + mapper=self._load_mapper( + augs=augs, + is_validation_split=(split == DatasetSplitType.VAL), + ), + ) # type: ignore diff --git a/focoos/data/catalog/__init__.py b/focoos/data/catalog/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/focoos/data/catalog/catalog.py b/focoos/data/catalog/catalog.py new file mode 100644 index 00000000..9607424f --- /dev/null +++ b/focoos/data/catalog/catalog.py @@ -0,0 +1,182 @@ +import os +from dataclasses import dataclass +from typing import Optional + +from focoos.data.catalog.utils import load_coco_json, load_sem_seg +from focoos.data.datasets.dict_dataset import DictDataset +from focoos.data.utils import filter_images_with_only_crowd_annotations +from focoos.ports import ( + DATASETS_DIR, + DatasetMetadata, + DatasetSplitType, + Task, +) + + +@dataclass +class CatalogSplit: + image_root: str + json_file: str + gt_root: Optional[str] = None # only for semantic/panoptic + filter_empty: bool = True + + +@dataclass +class CatalogDataset: + name: str + task: Task + train_split: CatalogSplit + val_split: CatalogSplit + test_split: Optional[CatalogSplit] = None + + +CATALOG = [ + CatalogDataset( + name="ade20k_semseg", + task=Task.SEMSEG, + train_split=CatalogSplit( + image_root="ADEChallengeData2016/images/training", + gt_root="ADEChallengeData2016/annotations_detectron2/training", + json_file="ADEChallengeData2016/ade20k_semseg_train.json", # trick to get classes + ), + val_split=CatalogSplit( + image_root="ADEChallengeData2016/images/validation", + gt_root="ADEChallengeData2016/annotations_detectron2/validation", + json_file="ADEChallengeData2016/ade20k_semseg_val.json", # trick to get classes + ), + ), + CatalogDataset( + name="voc_semseg", + task=Task.SEMSEG, + train_split=CatalogSplit( + image_root="PascalVOC12", + gt_root="PascalVOC12", + json_file="PascalVOC12/train.json", + ), + val_split=CatalogSplit( + image_root="PascalVOC12", + gt_root="PascalVOC12", + json_file="PascalVOC12/val.json", + ), + ), + CatalogDataset( + name="ade20k_instance", + task=Task.INSTANCE_SEGMENTATION, + train_split=CatalogSplit( + image_root="ADEChallengeData2016/images/training", + json_file="ADEChallengeData2016/ade20k_instance_train.json", + ), + val_split=CatalogSplit( + image_root="ADEChallengeData2016/images/validation", + json_file="ADEChallengeData2016/ade20k_instance_val.json", + filter_empty=False, + ), + ), + CatalogDataset( + name="coco_2017_det", + task=Task.DETECTION, + train_split=CatalogSplit( + image_root="coco/train2017", + json_file="coco/annotations/instances_train2017.json", + ), + val_split=CatalogSplit( + image_root="coco/val2017", + json_file="coco/annotations/instances_val2017.json", + filter_empty=False, + ), + ), + CatalogDataset( + name="coco_2017_instance", + task=Task.INSTANCE_SEGMENTATION, + train_split=CatalogSplit( + image_root="coco/train2017", + json_file="coco/annotations/instances_train2017.json", + ), + val_split=CatalogSplit( + image_root="coco/val2017", + json_file="coco/annotations/instances_val2017.json", + filter_empty=False, + ), + ), + CatalogDataset( + name="object365", + task=Task.DETECTION, + train_split=CatalogSplit( + image_root="object365/train", + json_file="object365/train/_annotations.coco.json", + ), + val_split=CatalogSplit( + image_root="object365/val", + json_file="object365/val/_annotations.coco.json", + filter_empty=False, + ), + ), +] + + +def _load_dataset_split( + split_name: str, + split: CatalogSplit, + task: Task, + root=DATASETS_DIR, +) -> DictDataset: + """ + This function can be used for loading datasets outside the catalog but with the same format + """ + + def get_path(root, path): + return os.path.join(root, path) + + json_file_path = get_path(root, split.json_file) + image_root_path = get_path(root, split.image_root) + gt_root_path = get_path(root, split.gt_root) if split.gt_root else None + + metadata = DatasetMetadata( + name=split_name, + num_classes=0, # will be overridden + json_file=json_file_path, + image_root=image_root_path, + task=task, + ) + + if task in [Task.DETECTION, Task.INSTANCE_SEGMENTATION]: + dataset_dict = load_coco_json(json_file_path, image_root_path, metadata, task=task) + if split.filter_empty: + dataset_dict = filter_images_with_only_crowd_annotations(dataset_dicts=dataset_dict) + elif task == Task.SEMSEG: + if not gt_root_path: + raise ValueError(f"Internal Error: gt_root missing from dataset {split_name}.") + metadata.sem_seg_root = gt_root_path + metadata.ignore_label = 255 + dataset_dict = load_sem_seg( + gt_root=gt_root_path, + image_root=image_root_path, + json_file=json_file_path, + metadata=metadata, + ) + else: + raise ValueError(f"Unknown task {task}") + + metadata.count = len(dataset_dict) + return DictDataset(dataset_dict, task=task, metadata=metadata) + + +def get_dataset_split(name: str, split: DatasetSplitType, datasets_root=DATASETS_DIR) -> DictDataset: + """ + Load a dataset split from the catalog. + """ + dataset_names = [ds.name for ds in CATALOG] + if name not in dataset_names: + raise ValueError(f"Dataset {name} not found. Available datasets: {dataset_names}") + + ds = next(ds for ds in CATALOG if ds.name == name) + if split == DatasetSplitType.TRAIN: + entry = ds.train_split + split_name = name + elif split == DatasetSplitType.VAL: + entry = ds.val_split + split_name = name + else: + raise ValueError(f"Unknown split {split}") + + return _load_dataset_split(split_name, entry, ds.task, datasets_root) diff --git a/focoos/data/catalog/utils.py b/focoos/data/catalog/utils.py new file mode 100644 index 00000000..26d9fd95 --- /dev/null +++ b/focoos/data/catalog/utils.py @@ -0,0 +1,296 @@ +import contextlib +import io +import json +import os + +import pycocotools.mask as mask_util + +from focoos.ports import DatasetMetadata, DetectronDict, Task +from focoos.structures import BoxMode +from focoos.utils.logger import get_logger +from focoos.utils.timer import Timer + +logger = get_logger(__name__) + + +def load_sem_seg( + gt_root, + image_root, + json_file, + metadata: DatasetMetadata, +): + with open(json_file) as f: + json_info = json.load(f) + + images = dict() + for info in json_info["images"]: + images[info["id"]] = info["file_name"] + + dataset_dicts = [] + for ann in json_info["annotations"]: + image_id = ann["image_id"] + + image_file = os.path.join(image_root, images[image_id]) + label_file = os.path.join(gt_root, ann["file_name"]) + + dataset_dicts.append(DetectronDict(file_name=image_file, sem_seg_file_name=label_file, image_id=image_id)) + + logger.info("Loaded {} images with semantic segmentation from {}".format(len(dataset_dicts), image_root)) + + # This is only useful for metadata + categories = json_info["categories"] + # All the classes are stuff, only a subset is thing + stuff_dataset_id_to_contiguous_id = {} + + for i, cat in enumerate(categories): + stuff_dataset_id_to_contiguous_id[cat["id"]] = i + + metadata.stuff_classes = [k["name"] for k in categories] + metadata.stuff_dataset_id_to_contiguous_id = stuff_dataset_id_to_contiguous_id + metadata.num_classes = len(categories) + if "color" in categories[0]: + metadata.stuff_colors = [k["color"] for k in categories] + + return dataset_dicts + + +def load_coco_json( + json_file, + image_root, + metadata: DatasetMetadata, + task: str, + extra_annotation_keys=None, +): + from pycocotools.coco import COCO + + timer = Timer() + # json_file = PathManager.get_local_path(json_file) + with contextlib.redirect_stdout(io.StringIO()): + coco_api = COCO(json_file) + if timer.seconds() > 1: + logger.info("Loading {} takes {:.2f} seconds.".format(json_file, timer.seconds())) + + cat_ids = sorted(coco_api.getCatIds()) + cats = coco_api.loadCats(cat_ids) + # The categories in a custom json file may not be sorted. + thing_classes = [c["name"] for c in sorted(cats, key=lambda x: x["id"])] + + # In COCO, certain category ids are artificially removed, + # and by convention they are always ignored. + # We deal with COCO's id issue and translate + # the category ids to contiguous ids in [0, 80). + + # It works by looking at the "categories" field in the json, therefore + # if users' own json also have incontiguous ids, we'll + # apply this mapping as well but print a warning. + id_map = {v: i for i, v in enumerate(cat_ids)} + + # sort indices for reproducible results + img_ids = sorted(coco_api.imgs.keys()) + # imgs is a list of dicts, each looks something like: + # {'license': 4, + # 'url': 'http://farm6.staticflickr.com/5454/9413846304_881d5e5c3b_z.jpg', + # 'file_name': 'COCO_val2014_000000001268.jpg', + # 'height': 427, + # 'width': 640, + # 'date_captured': '2013-11-17 05:57:24', + # 'id': 1268} + imgs = coco_api.loadImgs(img_ids) + # anns is a list[list[dict]], where each dict is an annotation + # record for an object. The inner list enumerates the objects in an image + # and the outer list enumerates over images. Example of anns[0]: + # [{'segmentation': [[192.81, + # 247.09, + # ... + # 219.03, + # 249.06]], + # 'area': 1035.749, + # 'iscrowd': 0, + # 'image_id': 1268, + # 'bbox': [192.81, 224.8, 74.73, 33.43], + # 'category_id': 16, + # 'id': 42986}, + # ...] + anns = [coco_api.imgToAnns[img_id] for img_id in img_ids] + total_num_valid_anns = sum([len(x) for x in anns]) + total_num_anns = len(coco_api.anns) + if total_num_valid_anns < total_num_anns: + logger.warning( + f"{json_file} contains {total_num_anns} annotations, but only " + f"{total_num_valid_anns} of them match to images in the file." + ) + + imgs_anns = list(zip(imgs, anns)) + logger.info("Loaded {} images in COCO format from {}".format(len(imgs_anns), json_file)) + + dataset_dicts = [] + + ann_keys = ["iscrowd", "bbox", "keypoints", "category_id", "area"] + (extra_annotation_keys or []) + + num_instances_without_valid_segmentation = 0 + + for img_dict, anno_dict_list in imgs_anns: + image_id = img_dict["id"] + record = DetectronDict( + file_name=os.path.join(image_root, img_dict["file_name"]), + height=img_dict["height"], + width=img_dict["width"], + image_id=img_dict["id"], + ) + objs = [] + for anno in anno_dict_list: + # Check that the image_id in this annotation is the same as + # the image_id we're looking at. + # This fails only when the data parsing logic or the annotation file is buggy. + + # The original COCO valminusminival2014 & minival2014 annotation files + # actually contains bugs that, together with certain ways of using COCO API, + # can trigger this assertion. + assert anno["image_id"] == image_id + + assert anno.get("ignore", 0) == 0, '"ignore" in COCO json file is not supported.' + + obj = {key: anno[key] for key in ann_keys if key in anno} + + if "bbox" in obj and len(obj["bbox"]) == 0: + raise ValueError( + f"One annotation of image {image_id} contains empty 'bbox' value! " + "This json does not have valid COCO format." + ) + + segm = anno.get("segmentation", None) + if segm is not None and task == Task.INSTANCE_SEGMENTATION: # either list[list[float]] or dict(RLE) + if isinstance(segm, dict): + if isinstance(segm["counts"], list): + # convert to compressed RLE + segm = mask_util.frPyObjects(segm, *segm["size"]) + else: + # filter out invalid polygons (< 3 points) + segm = [poly for poly in segm if len(poly) % 2 == 0 and len(poly) >= 6] + if len(segm) == 0: + num_instances_without_valid_segmentation += 1 + continue # ignore this instance + obj["segmentation"] = segm + + keypts = anno.get("keypoints", None) + if keypts: # list[int] + for idx, v in enumerate(keypts): + if idx % 3 != 2: + # COCO's segmentation coordinates are floating points in [0, H or W], + # but keypoint coordinates are integers in [0, H-1 or W-1] + # Therefore we assume the coordinates are "pixel indices" and + # add 0.5 to convert to floating point coordinates. + keypts[idx] = v + 0.5 + obj["keypoints"] = keypts + + obj["bbox_mode"] = BoxMode.XYWH_ABS + if id_map: + annotation_category_id = obj["category_id"] + try: + obj["category_id"] = id_map[annotation_category_id] + except KeyError as e: + raise KeyError( + f"Encountered category_id={annotation_category_id} " + "but this id does not exist in 'categories' of the json file." + ) from e + objs.append(obj) + record.annotations = objs + dataset_dicts.append(record) + + if num_instances_without_valid_segmentation > 0: + logger.warning( + "Filtered out {} instances without valid segmentation. ".format(num_instances_without_valid_segmentation) + + "There might be issues in your dataset generation process. Please " + "check https://detectron2.readthedocs.io/en/latest/tutorials/datasets.html carefully" + ) + + # Fill metadata information + metadata.num_classes = len(thing_classes) + metadata.thing_classes = thing_classes + metadata.thing_dataset_id_to_contiguous_id = id_map + if "color" in cats[0]: + thing_colors = [c["color"] for c in sorted(cats, key=lambda x: x["id"])] + metadata.thing_colors = thing_colors + + return dataset_dicts + + +def load_coco_panoptic_json(json_file, image_dir, gt_dir, metadata: DatasetMetadata): + """ + Args: + image_dir (str): path to the raw dataset. e.g., "~/coco/train2017". + gt_dir (str): path to the raw annotations. e.g., "~/coco/panoptic_train2017". + json_file (str): path to the json file. e.g., "~/coco/annotations/panoptic_train2017.json". + + Returns: + list[dict]: a list of dicts in Detectron2 standard format. + """ + + def _convert_category_id( + segment_info, + thing_dataset_id_to_contiguous_id, + stuff_dataset_id_to_contiguous_id, + ): + if segment_info["category_id"] in thing_dataset_id_to_contiguous_id: + segment_info["category_id"] = thing_dataset_id_to_contiguous_id[segment_info["category_id"]] + segment_info["isthing"] = True + else: + segment_info["category_id"] = stuff_dataset_id_to_contiguous_id[segment_info["category_id"]] + segment_info["isthing"] = False + return segment_info + + with open(json_file) as f: + json_info = json.load(f) + + categories = json_info["categories"] + # All the classes are stuff, only a subset is thing + thing_dataset_id_to_contiguous_id = {} + stuff_dataset_id_to_contiguous_id = {} + + for i, cat in enumerate(categories): + if cat["isthing"]: + thing_dataset_id_to_contiguous_id[cat["id"]] = i + stuff_dataset_id_to_contiguous_id[cat["id"]] = i + + metadata.thing_classes = [k["name"] for k in categories if k["isthing"] == 1] + metadata.stuff_classes = [k["name"] for k in categories] + metadata.stuff_dataset_id_to_contiguous_id = stuff_dataset_id_to_contiguous_id + metadata.thing_dataset_id_to_contiguous_id = thing_dataset_id_to_contiguous_id + metadata.num_classes = len(categories) + if "color" in categories[0]: + metadata.thing_colors = [k["color"] for k in categories if k["isthing"] == 1] + metadata.stuff_colors = [k["color"] for k in categories] + + images = dict() + for info in json_info["images"]: + images[info["id"]] = info["file_name"] + + ret = [] + for ann in json_info["annotations"]: + image_id = ann["image_id"] + + image_file = os.path.join(image_dir, images[image_id]) + label_file = os.path.join(gt_dir, ann["file_name"]) + segments_info = [ + _convert_category_id(x, thing_dataset_id_to_contiguous_id, stuff_dataset_id_to_contiguous_id) + for x in ann["segments_info"] + ] + ret.append( + DetectronDict( + file_name=image_file, + image_id=image_id, + pan_seg_file_name=label_file, + segments_info=segments_info, + ) + ) + return ret + + +def replace_path_prefix(path: str, new_prefix: str) -> str: + parts = path.split("/") + return "/".join([new_prefix] + parts[1:]) + + +def remove_prefix(path: str) -> str: + parts = path.split("/") + return "/".join(parts[1:]) diff --git a/focoos/data/converters.py b/focoos/data/converters.py new file mode 100644 index 00000000..bc860d10 --- /dev/null +++ b/focoos/data/converters.py @@ -0,0 +1,592 @@ +import base64 +import concurrent.futures +import csv +import datetime +import json +import os +import random +import shutil +import zlib +from pathlib import Path +from typing import List + +import cv2 +import numpy as np +from PIL import Image +from tqdm import tqdm + +from focoos.data.datasets.dict_dataset import DictDataset +from focoos.data.transforms.resize_short_length import resize_shortest_length +from focoos.ports import DatasetMetadata, Task +from focoos.utils.logger import get_logger +from focoos.utils.system import list_files_with_extensions + +logger = get_logger(__name__) + + +def get_random_color(): + return [random.randint(0, 255) for _ in range(3)] + + +def base64_to_numpy(base64_string): + image_data = zlib.decompress(base64.b64decode(base64_string)) + image = cv2.imdecode(np.frombuffer(image_data, np.uint8), cv2.IMREAD_UNCHANGED)[:, :, 3].astype(np.bool) + return image + + +def get_classes(json_file: str, use_background: bool = False, ignore_classes: List[str] = []): + with open(json_file, "r") as f: + data = json.load(f) + classes = data["classes"] + + new_classes = {"background": 0} if use_background else {} + for idx, cls in enumerate(classes): + if cls["title"] not in ignore_classes: + new_classes[cls["title"]] = idx + 1 if use_background else idx + return new_classes + + +def convert_json_to_png(json_file: str, class_to_id, use_background: bool = False, ignore_classes: List[str] = []): + with open(json_file, "r") as f: + data = json.load(f) + + if use_background: + output_png = np.zeros((data["size"]["height"], data["size"]["width"]), dtype=np.uint8) + else: + output_png = np.zeros((data["size"]["height"], data["size"]["width"]), dtype=np.uint8) - 1 + + for annotation in data["objects"]: + class_name = annotation["classTitle"] + class_id = class_to_id[class_name] if use_background else class_to_id[class_name] + 1 + if class_name in ignore_classes: + class_id = 255 + if annotation["geometryType"] == "bitmap": + origin = np.array(annotation["bitmap"]["origin"]) + mask_b64 = annotation["bitmap"]["data"] + mask = base64_to_numpy(mask_b64) + + output_png[origin[1] : origin[1] + mask.shape[0], origin[0] : origin[0] + mask.shape[1]][mask] = class_id + else: + print(f"Warning: Unsupported geometry type: {annotation['geometryType']}") + + return output_png + + +def convert_supervisely_dataset_to_png( + dataset_root, remove_json=False, use_background=False, ignore_classes=[], ignore_folders=[] +): + """ + Convert Supervisely dataset annotations to mask format. + + This function processes Supervisely-formatted JSON annotations and converts them into PNG mask files. + Each pixel in the output mask is assigned a class ID corresponding to its annotation. + + Args: + dataset_root (str): Path to the root directory of the dataset. + remove_json (bool, optional): Whether to remove the original JSON files after conversion. Defaults to False. + use_background (bool, optional): If True, assigns class ID 0 to non-annotated pixels and shifts other class IDs by 1. Defaults to False. + ignore_classes (List[str], optional): List of class names to ignore during conversion. Defaults to []. + + Expected Directory Structure: + dataset_root/ + meta.json + {train/val/test/any}/ + {image_name}/ + file1.jpg + file2.jpg + {ann_name}/ + file1.json + file2.json + + Returns: + None: Creates PNG mask files in the same directory as the input JSON files. + """ + class_to_id = get_classes(os.path.join(dataset_root, "meta.json")) + for folder in os.listdir(dataset_root): + if os.path.isfile(os.path.join(dataset_root, folder)) or folder in ignore_folders: + continue + logger.info(f"Processing folder {folder}") + for subfolder in os.listdir(os.path.join(dataset_root, folder)): + if os.path.isfile(os.path.join(dataset_root, folder, subfolder)) or subfolder in ignore_folders: + continue + for file in os.listdir(os.path.join(dataset_root, folder, subfolder)): + if file.endswith(".json"): + png_output = convert_json_to_png( + os.path.join(dataset_root, folder, subfolder, file), + class_to_id, + use_background, + ignore_classes, + ) + Image.fromarray(png_output).save( + os.path.join(dataset_root, folder, subfolder, file.replace(".jpg.json", ".png")) + ) + if remove_json: + os.remove(os.path.join(dataset_root, folder, subfolder, file)) + + +def create_segmentation_json( + root_dir: str, + image_folder: str, + mask_folder: str, + classes: List[str], + output_file: str = "annotations.json", + image_extensions: List[str] = [".jpg", ".jpeg", ".png", ".bmp", ".tiff", ".tif"], + mask_suffix: str = ".png", +): + """ + Create a json file for a segmentation dataset + + The classes should be a list of strings following the id in the png file. + + The dataset is expected to be in the following structure: + root_dir/ -> This is likely the split folder (train, val, test, etc.) + {image_folder}/ + {image_name}.{image_extension} + {mask_folder}/ + {image_name}.{mask_suffix} + + It will create a json file with the following structure that will be accepted as the DictDataset.from_segmentation input. + { + "images": [ + { + "id": int, + "file_name": str, + "height": int, + "width": int, + }, + ... + ], + "annotations": [ + { + "image_id": int, + "file_name": str, + }, + ... + ], + "categories": [ + { + "id": int, + "name": str, + "color": [int, int, int], + "is_thing": bool, + }, + ... + ] + } + """ + images = [] + annotations = [] + categories = [] + + # Create a mapping from class name to class ID + class_to_id = {cls: i for i, cls in enumerate(classes)} + for class_name in classes: + categories.append( + { + "id": class_to_id[class_name], + "name": class_name, + "color": get_random_color(), + "is_thing": True, + } + ) + + for idx, image in enumerate(os.listdir(os.path.join(root_dir, image_folder))): + if Path(image).suffix not in image_extensions: + continue + mask_path = os.path.join(mask_folder, Path(image).stem + mask_suffix) + + if not os.path.exists(os.path.join(root_dir, mask_path)): + print(f"Warning: Mask file {mask_path} does not exist") + continue + + image_path = os.path.join(root_dir, image_folder, image) + try: + with Image.open(image_path) as img: + width, height = img.size + except Exception: + print(f"Warning: Image file {image_path} is not a valid image") + continue + + images.append( + { + "id": idx, + "file_name": os.path.join(image_folder, image), + "height": height, + "width": width, + } + ) + + annotations.append( + { + "image_id": idx, + "file_name": mask_path, + } + ) + + json_data = { + "images": images, + "annotations": annotations, + "categories": categories, + } + + with open(os.path.join(root_dir, output_file), "w") as f: + json.dump(json_data, f) + + +def convert_to_mask_format(dict_dataset: DictDataset, new_data_dir: str): + """ + Convert a DictDataset to the Mask Format of roboflow. + The output directory will be structured as follows: + new_data_dir/ -> This is likely the split folder (train, val, test, etc.) + _classes.csv + {image_name}.{image_extension} + {image_name}_mask.png + + The classes.csv file will be created with the following structure: + Pixel Value,Class + 0,unlabeled + + """ + assert dict_dataset.metadata.task == Task.SEMSEG, "Error, not a SEMSEG dataset" + os.makedirs(new_data_dir, exist_ok=True) + + # Create classes.csv file + classes_file = os.path.join(new_data_dir, "_classes.csv") + with open(classes_file, "w", newline="") as f: + writer = csv.writer(f) + writer.writerow(["Pixel Value", "Class"]) # Write header + for class_id, class_name in enumerate(dict_dataset.metadata.classes): + writer.writerow([class_id, class_name]) + + for diz in dict_dataset: + image = diz["file_name"] + mask = diz["sem_seg_file_name"] + new_img_path = Path(image).name + new_mask_path = new_img_path[:-4] + "_mask.png" + shutil.copy(image, os.path.join(new_data_dir, new_img_path)) + shutil.copy(mask, os.path.join(new_data_dir, new_mask_path)) + + +def clone_resize_shortest_length(dataset: DictDataset, new_dir: str, new_shortest_length: int = 1024, max_size=2048): + """ + Clone and resize DatasetDict images and masks to a new directory with a specified shortest length. and max size + + Parameters: + new_dir (str): The directory path where the cloned and resized images and masks will be saved. + new_shortest_length (int, optional): The new shortest length to resize the images and masks to. Defaults to 1024. + max_size: The maximum size for the resized images and masks. Defaults to 2048. + """ + logger = get_logger(__name__) + logger.info("[START RESIZE] clone_resize_shortest_length ") + pool = concurrent.futures.ThreadPoolExecutor(max_workers=150) + os.makedirs(new_dir, exist_ok=True) + # !TODO generalize for other task + im_dir = os.path.join(new_dir, "img") + mask_dir = os.path.join(new_dir, "mask") + metadata_path = os.path.join(new_dir, "focoos_meta.json") + os.makedirs(im_dir, exist_ok=True) + os.makedirs(mask_dir, exist_ok=True) + orig_meta = dataset.metadata + + for data in dataset.dicts: # type: ignore + im_file = data.file_name + mask_file = data.sem_seg_file_name + pool.submit( + resize_shortest_length, + im_file, + im_dir, + new_shortest_length, + max_size, + False, + ) + pool.submit( + resize_shortest_length, + mask_file, # type: ignore + mask_dir, + new_shortest_length, + max_size, + True, + ) + pool.shutdown(wait=True) + count = len(list_files_with_extensions(base_dir=im_dir, extensions=["png", "jpeg", "jpg"])) + metadata = DatasetMetadata( + count=count, + num_classes=orig_meta.num_classes, + task=orig_meta.task, + thing_classes=orig_meta.thing_classes, + stuff_classes=orig_meta.stuff_classes, + ) + + metadata.dump_json(metadata_path) + logger.info("[END resize]") + + +def get_annotation_dict_from_json_file(json_file: str, image_id, start_annotation_id, class_to_id): + with open(json_file, "r") as f: + data = json.load(f) + + annotations = [] + annotation_id = start_annotation_id + for annotation in data["objects"]: + if annotation["geometryType"] == "rectangle": + if annotation["classTitle"] not in class_to_id: + logger.info(f"Skipping annotation {annotation['classTitle']} because it is ignored") + continue + class_id = class_to_id[annotation["classTitle"]] + 1 # in COCO the 0 is ignored + bbox = annotation["points"]["exterior"] + bbox = np.array( + [bbox[0][0], bbox[0][1], bbox[1][0] - bbox[0][0], bbox[1][1] - bbox[0][1]], dtype=np.float32 + ) # Convert to xyxy format + area = bbox[2] * bbox[3] # Calculate area from xyxy coordinates + annotations.append( + { + "id": annotation_id, + "image_id": image_id, + "category_id": int(class_id), + "bbox": bbox.tolist(), + "area": int(area), + "segmentation": [], + "iscrowd": 0, + } + ) + annotation_id += 1 + else: + raise ValueError(f"Unsupported geometry type: {annotation['geometryType']}") + + return annotations + + +def convert_datasetninja_to_mask_dataset( + dataset_root: str, + dataset_name: str, + new_name: str, + image_folder: str, + mask_folder: str, + ignore_folders: List[str] = [], + use_background: bool = True, + ignore_classes: List[str] = [], + train_split_name: str = "train", + val_split_name: str = "val", + remove_json: bool = False, +): + """Convert a DatasetNinja dataset to a mask-based segmentation dataset format. + + This function performs a multi-step conversion process: + 1. Converts DatasetNinja JSON annotations to PNG masks + 2. Creates segmentation JSON files for train and validation splits + 3. Converts the dataset to a mask-based format compatible with RoboflowMask format + + Args: + dataset_root (str): Root directory containing the dataset + dataset_name (str): Name of the source DatasetNinja dataset folder + new_name (str): Name for the converted dataset folder + image_folder (str): Name of the folder containing images + mask_folder (str): Name of the folder containing masks + ignore_folders (List[str], optional): List of folders to ignore during conversion. Defaults to []. + use_background (bool, optional): Whether to include background class. Defaults to True. + ignore_classes (List[str], optional): List of classes to ignore. Defaults to []. + train_split_name (str, optional): Name of the training split folder. Defaults to "train". + val_split_name (str, optional): Name of the validation split folder. Defaults to "test". + remove_json (bool, optional): Whether to remove original JSON files after conversion. Defaults to False. + + Expected Directory Structure: + dataset_root/ + dataset_name/ + meta.json + {train_split_name}/ + {image_folder}/ + image1.jpg + image2.jpg + {mask_folder}/ + image1.json + image2.json + {val_split_name}/ + {image_folder}/ + image1.jpg + image2.jpg + {mask_folder}/ + image1.json + image2.json + + Output Dataset Structure: + dataset_root/ + new_dataset_name/ + train/ + _classes.csv + image1.jpg + image1_mask.png + val/ + _classes.csv + image1.jpg + image1_mask.png + + Returns: + None: The converted dataset is saved to the specified output directory. + """ + dataset_path = os.path.join(dataset_root, dataset_name) + new_dataset_path = os.path.join(dataset_root, new_name) + + logger.info(f"Converting {dataset_name} from DatasetNinja Json to PNG") + convert_supervisely_dataset_to_png( + dataset_root=dataset_path, + use_background=use_background, + ignore_classes=ignore_classes, + ignore_folders=ignore_folders, + remove_json=remove_json, + ) + + classes = get_classes( + os.path.join(dataset_path, "meta.json"), use_background=use_background, ignore_classes=ignore_classes + ) + logger.info(f"Classes: {classes}") + + for split in [train_split_name, val_split_name]: + logger.info(f"Creating segmentation json for {split}") + create_segmentation_json( + root_dir=os.path.join(dataset_path, split), + image_folder=image_folder, + mask_folder=mask_folder, + classes=list(classes.keys()), + ) + + task = Task.SEMSEG + train_dataset = DictDataset.from_segmentation(ds_dir=os.path.join(dataset_path, train_split_name), task=task) + logger.info(f"Train dataset: {train_dataset}") + + val_dataset = DictDataset.from_segmentation(ds_dir=os.path.join(dataset_path, val_split_name), task=task) + logger.info(f"Val dataset: {val_dataset}") + + for split in [(train_dataset, "train"), (val_dataset, "val")]: + logger.info(f"Converting {split[1]} dataset to mask format into") + convert_to_mask_format(dict_dataset=split[0], new_data_dir=os.path.join(new_dataset_path, split[1])) + + +def convert_supervisely_dataset_to_coco( + dataset_root: str, + dataset_name: str, + new_name: str, + image_folder: str, + mask_folder: str, + ignore_classes: List[str] = [], + train_split_name: str = "train", + val_split_name: str = "val", + remove_json: bool = False, +): + """ + Convert Supervisely dataset annotations to COCO format. + + This function processes Supervisely-formatted JSON annotations and converts them into COCO format. + The conversion preserves image metadata, annotations, and class information while adapting to COCO's + specific structure and requirements. + + Args: + dataset_root (str): Path to the root directory of the dataset. + dataset_name (str): Name of the dataset directory containing the Supervisely annotations. + new_name (str): Name for the converted COCO dataset. + image_folder (str): Name of the folder containing the images. + mask_folder (str): Name of the folder containing the annotation files. + ignore_classes (List[str], optional): List of class names to ignore during conversion. Defaults to []. + train_split_name (str, optional): Name of the training split folder. Defaults to "train". + val_split_name (str, optional): Name of the validation split folder. Defaults to "val". + remove_json (bool, optional): Whether to remove the original JSON files after conversion. Defaults to False. + + Expected Directory Structure: + dataset_root/ + dataset_name/ + meta.json + {train_split_name}/ + {image_folder}/ + image1.jpg + image2.jpg + {mask_folder}/ + image1.json + image2.json + {val_split_name}/ + {image_folder}/ + image1.jpg + image2.jpg + {mask_folder}/ + image1.json + image2.json + + Output Dataset Structure: + dataset_root/ + new_dataset_name/ + train/ + _annotations.coco.json + image1.jpg + val/ + _annotations.coco.json + image1.jpg + + Returns: + None: Creates a new directory with COCO-formatted annotations and copies images to the new structure. + """ + dataset_path = os.path.join(dataset_root, dataset_name) + new_dataset_path = os.path.join(dataset_root, new_name) + + class_to_id = get_classes(os.path.join(dataset_path, "meta.json"), ignore_classes=ignore_classes) + + info = { + "year": "2025", + "description": "Converted with Focoos", + "date_created": datetime.datetime.now().strftime("%Y-%m-%dT%H:%M:%S+00:00"), + } + categories = [] + + for class_name, class_id in class_to_id.items(): + categories.append( + { + "id": class_id + 1, # in COCO the 0 is ignored + "name": class_name, + "supercategory": "superclass", + } + ) + + for split in [(train_split_name, "train"), (val_split_name, "val")]: + images = [] + annotations = [] + + os.makedirs(os.path.join(new_dataset_path, split[1]), exist_ok=True) + + logger.info(f"Processing folder {split[0]}") + for file in tqdm(os.listdir(os.path.join(dataset_path, split[0], image_folder))): + try: + image = Image.open(os.path.join(dataset_path, split[0], image_folder, file)) + width, height = image.size + except Exception: + logger.warning(f"Image {file} is not a valid image") + continue + + images.append( + { + "id": len(images), + "file_name": file, + "height": height, + "width": width, + } + ) + shutil.copy( + os.path.join(dataset_path, split[0], image_folder, file), os.path.join(new_dataset_path, split[1], file) + ) + + image_annotations = get_annotation_dict_from_json_file( + os.path.join(dataset_path, split[0], mask_folder, file + ".json"), + len(images) - 1, + len(annotations), + class_to_id, + ) + if remove_json: + os.remove(os.path.join(dataset_path, split[0], mask_folder, file, ".json")) + + annotations.extend(image_annotations) + + coco_json = { + "info": info, + "categories": categories, + "images": images, + "annotations": annotations, + } + + with open(os.path.join(new_dataset_path, split[1], "_annotations.coco.json"), "w") as f: + json.dump(coco_json, f) diff --git a/focoos/data/datasets/__init__.py b/focoos/data/datasets/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/focoos/data/datasets/common.py b/focoos/data/datasets/common.py new file mode 100644 index 00000000..627fd263 --- /dev/null +++ b/focoos/data/datasets/common.py @@ -0,0 +1,134 @@ +import itertools + +import torch.utils.data as torchdata + + +# copied from: https://docs.python.org/3/library/itertools.html#recipes +def _roundrobin(*iterables): + "roundrobin('ABC', 'D', 'EF') --> A D E B F C" + # Recipe credited to George Sakkis + num_active = len(iterables) + nexts = itertools.cycle(iter(it).__next__ for it in iterables) + while num_active: + try: + for next in nexts: + yield next() + except StopIteration: + # Remove the iterator we just exhausted from the cycle. + num_active -= 1 + nexts = itertools.cycle(itertools.islice(nexts, num_active)) + + +def _shard_iterator_dataloader_worker(iterable, chunk_size=1): + # Shard the iterable if we're currently inside pytorch dataloader worker. + worker_info = torchdata.get_worker_info() + if worker_info is None or worker_info.num_workers == 1: + # do nothing + yield from iterable + else: + # worker0: 0, 1, ..., chunk_size-1, num_workers*chunk_size, num_workers*chunk_size+1, ... + # worker1: chunk_size, chunk_size+1, ... + # worker2: 2*chunk_size, 2*chunk_size+1, ... + # ... + yield from _roundrobin( + *[ + itertools.islice( + iterable, + worker_info.id * chunk_size + chunk_i, + None, + worker_info.num_workers * chunk_size, + ) + for chunk_i in range(chunk_size) + ] + ) + + +class AspectRatioGroupedDataset(torchdata.IterableDataset): + """ + Batch data that have similar aspect ratio together. + In this implementation, images whose aspect ratio < (or >) 1 will + be batched together. + This improves training speed because the images then need less padding + to form a batch. + + It assumes the underlying dataset produces dicts with "width" and "height" keys. + It will then produce a list of original dicts with length = batch_size, + all with similar aspect ratios. + """ + + def __init__(self, dataset, batch_size): + """ + Args: + dataset: an iterable. Each element must be a dict with keys + "width" and "height", which will be used to batch data. + batch_size (int): + """ + self.dataset = dataset + self.batch_size = batch_size + self._buckets = [[] for _ in range(2)] + # Hard-coded two aspect ratio groups: w > h and w < h. + # Can add support for more aspect ratio groups, but doesn't seem useful + + def __iter__(self): + for d in self.dataset: + w, h = d.width, d.height + bucket_id = 0 if w > h else 1 + bucket = self._buckets[bucket_id] + bucket.append(d) + if len(bucket) == self.batch_size: + data = bucket[:] + # Clear bucket first, because code after yield is not + # guaranteed to execute + del bucket[:] + yield data + + +class ToIterableDataset(torchdata.IterableDataset): + """ + Convert an old indices-based (also called map-style) dataset + to an iterable-style dataset. + """ + + def __init__( + self, + dataset: torchdata.Dataset, + sampler: torchdata.Sampler, + shard_sampler: bool = True, + shard_chunk_size: int = 1, + ): + """ + Args: + dataset: an old-style dataset with ``__getitem__`` + sampler: a cheap iterable that produces indices to be applied on ``dataset``. + shard_sampler: whether to shard the sampler based on the current pytorch data loader + worker id. When an IterableDataset is forked by pytorch's DataLoader into multiple + workers, it is responsible for sharding its data based on worker id so that workers + don't produce identical data. + + Most samplers (like our TrainingSampler) do not shard based on dataloader worker id + and this argument should be set to True. But certain samplers may be already + sharded, in that case this argument should be set to False. + shard_chunk_size: when sharding the sampler, each worker will + """ + assert not isinstance(dataset, torchdata.IterableDataset), dataset + assert isinstance(sampler, torchdata.Sampler), sampler + self.dataset = dataset + self.sampler = sampler + self.shard_sampler = shard_sampler + self.shard_chunk_size = shard_chunk_size + + def __iter__(self): + if not self.shard_sampler: + sampler = self.sampler + else: + # With map-style dataset, `DataLoader(dataset, sampler)` runs the + # sampler in main process only. But `DataLoader(ToIterableDataset(dataset, sampler))` + # will run sampler in every of the N worker. So we should only keep 1/N of the ids on + # each worker. The assumption is that sampler is cheap to iterate so it's fine to + # discard ids in workers. + sampler = _shard_iterator_dataloader_worker(self.sampler, self.shard_chunk_size) + for idx in sampler: + yield self.dataset[idx] + + def __len__(self): + return len(self.sampler) # type: ignore diff --git a/focoos/data/datasets/dict_dataset.py b/focoos/data/datasets/dict_dataset.py new file mode 100644 index 00000000..e0017849 --- /dev/null +++ b/focoos/data/datasets/dict_dataset.py @@ -0,0 +1,504 @@ +import csv +import json +import os +import random +from copy import copy +from dataclasses import asdict +from pathlib import Path +from typing import Optional, Tuple, Union + +import numpy as np +from PIL import Image +from torch.utils.data import Dataset + +from focoos.data.datasets.serialize import TorchSerializedDataset +from focoos.ports import ( + DatasetMetadata, + DatasetSplitType, + DetectronDict, + Task, +) +from focoos.utils.logger import get_logger +from focoos.utils.system import list_files_with_extensions + + +def remove_none_from_dict(data): + return {k: v for (k, v) in data if v is not None} + + +class DictDataset(Dataset): + def __init__( + self, + dicts: list[DetectronDict], + task: Task, + metadata: DatasetMetadata, + serialize: bool = True, + ): + self.task: Task = task + self.metadata: DatasetMetadata = metadata + # self.dicts: list[DetectronDict] = dicts + # assemble detectron standard dict + self.logger = get_logger(__name__) + self.logger.info( + f"[Focoos-DictDataset] dataset {self.metadata.name} loaded. len: {self.metadata.count}, classes:{self.metadata.num_classes} ,{self.metadata.image_root}" + ) + for i, d in enumerate(dicts): + d.image_id = i + + self.serialize = serialize + self.dicts: Union[TorchSerializedDataset, list[DetectronDict]] = ( + TorchSerializedDataset(dicts) if serialize else dicts + ) + + def __getitem__(self, index) -> dict: + entry = self.dicts[index] + return asdict(entry, dict_factory=remove_none_from_dict) + + def __len__(self): + return len(self.dicts) + + def store_coco_roboflow_format(self, output_dir: str): + """ + Store the dataset in COCO format. + """ + + def compute_area_seg(seg): + # let's assume the format is Polygon + # Convert list of points to numpy array + points = np.array(seg[0]).reshape(-1, 2) + + # Calculate area using shoelace formula + x = points[:, 0] + y = points[:, 1] + area = 0.5 * np.abs(np.dot(x, np.roll(y, 1)) - np.dot(y, np.roll(x, 1))) + return area + + def compute_area_box(bbox): + return bbox[2] * bbox[3] # we assume format is XYWH + + json_dict = { + "info": { + "year": "2025", + "version": "1", + "description": "Exported from FocoosAi", + }, + "categories": [ + { + "id": 0, + "name": "custom_class", + "supercategory": "none", + }, + ], + "images": [], + "annotations": [], + } + if self.metadata.thing_classes is None: + raise ValueError("thing_classes is None") + for i, cls in enumerate(self.metadata.thing_classes): + json_dict["categories"].append( + { + "id": i + 1, + "name": cls, + "supercategory": "custom_class", + }, + ) + annotation_idx = 1 + for i, data in enumerate(self.dicts): # type: ignore + json_dict["images"].append( + { + "id": data.image_id, + "file_name": data.file_name.split("/")[-1], + "height": data.height, + "width": data.width, + } + ) + for ann in data.annotations: + use_seg = "segmentation" in ann + area = compute_area_seg(ann["segmentation"]) if use_seg else compute_area_box(ann["bbox"]) + obj = { + "id": annotation_idx, + "image_id": data.image_id, + "category_id": ann["category_id"], + "bbox": ann["bbox"], + "area": area, # to compute + "iscrowd": ann["iscrowd"], + } + if use_seg: + obj["segmentation"] = ann["segmentation"] + + json_dict["annotations"].append(obj) + annotation_idx += 1 + + with open(os.path.join(output_dir, "_annotations.coco.json"), "w") as f: + json.dump(json_dict, f) + + @classmethod + def from_catalog(cls, ds_name: str, split: DatasetSplitType, root: str): + from focoos.data.catalog.catalog import get_dataset_split + + # importing catalog here is the only way to avoid circular input + return get_dataset_split(name=ds_name, split=split, datasets_root=root) + + @classmethod + def from_folder(cls, root_dir: str, split: Optional[DatasetSplitType] = None): + """ + Create a dataset from a folder structure where categories are subfolders + and images belonging to each category are inside these subfolders. + + Args: + root_dir (str): Path to the root directory containing category subfolders + split (Optional[DatasetSplitType]): Dataset split type (train, val, test) + If provided, looks for the split in the root_dir/split directory + + Returns: + ClassificationDataset: A dataset containing the images and their class labels + """ + logger = get_logger(__name__) + + # If split is provided, update the root directory to include the split + if split is not None: + root_dir = os.path.join(root_dir, split.value) + if not os.path.exists(root_dir): + raise ValueError(f"Split directory {root_dir} does not exist") + + # Get all category directories + category_dirs = [ + d for d in os.listdir(root_dir) if os.path.isdir(os.path.join(root_dir, d)) and not d.startswith(".") + ] + + if not category_dirs: + raise ValueError(f"No category directories found in {root_dir}") + + # Sort categories for deterministic ordering + category_dirs.sort() + + # Create a mapping from category name to class ID + class_to_idx = {cls_name: i for i, cls_name in enumerate(category_dirs)} + + # Initialize lists to store dataset entries + dataset_dicts = [] + + # Process each category + for category in category_dirs: + category_path = os.path.join(root_dir, category) + class_id = class_to_idx[category] + + # Find all image files in the category folder + image_extensions = ["jpg", "jpeg", "png", "bmp", "tiff", "tif"] + image_files = list_files_with_extensions(category_path, image_extensions) + + # Create a dataset entry for each image + for img_path in image_files: + try: + # Open image to get dimensions + with Image.open(img_path) as img: + width, height = img.size + + # Create dataset entry + entry = DetectronDict( + file_name=str(img_path), + height=height, + width=width, + # Store the class label as an annotation for compatibility with the DictDataset structure + annotations=[{"category_id": class_id, "iscrowd": 0}], + ) + dataset_dicts.append(entry) + except (IOError, OSError) as e: + logger.warning(f"Error loading image {img_path}: {e}") + + # Create dataset metadata + metadata = DatasetMetadata( + num_classes=len(category_dirs), + thing_classes=category_dirs, # Use thing_classes for classification + task=Task.CLASSIFICATION, + count=len(dataset_dicts), + name=Path(root_dir).name, + image_root=root_dir, + ) + + logger.info(f"Created classification dataset with {len(dataset_dicts)} images and {len(category_dirs)} classes") + + return cls(dicts=dataset_dicts, task=Task.CLASSIFICATION, metadata=metadata) + + @classmethod + def from_roboflow_coco(cls, ds_dir: str, task: Task): + """ + ds_dir is up to the split. + root/ + test/ + .. + valid/ + .. + train/ + _annotations.coco.json + im0.jpeg + """ + import pycocotools.mask as mask_util + from pycocotools.coco import COCO + + from focoos.structures import BoxMode + + json_file = os.path.join(ds_dir, "_annotations.coco.json") + coco_api = COCO(json_file) + + cat_ids = sorted(coco_api.getCatIds()) + for cat_id in cat_ids: # remove class 0 if exists + if cat_id <= 0: + cat_ids.pop(cat_id) + cats = coco_api.loadCats(cat_ids) + # The categories in a custom json file may not be sorted. + thing_classes = [c["name"] for c in sorted(cats, key=lambda x: x["id"])] + id_map = {v: i for i, v in enumerate(cat_ids)} + + img_ids = sorted(coco_api.imgs.keys()) + imgs = coco_api.loadImgs(img_ids) + anns = [coco_api.imgToAnns[img_id] for img_id in img_ids] + + imgs_anns = list(zip(imgs, anns)) + + dataset_dicts = [] + + ann_keys = ["iscrowd", "bbox", "keypoints", "category_id", "area"] + + num_instances_without_valid_segmentation = 0 + + for img_dict, anno_dict_list in imgs_anns: + record = {} + record["file_name"] = os.path.join(ds_dir, img_dict["file_name"]) + record["height"] = img_dict["height"] + record["width"] = img_dict["width"] + image_id = record["image_id"] = img_dict["id"] + + objs = [] + for anno in anno_dict_list: + assert anno["image_id"] == image_id + + assert anno.get("ignore", 0) == 0, '"ignore" in COCO json file is not supported.' + + obj = {key: anno[key] for key in ann_keys if key in anno} + if "bbox" in obj and len(obj["bbox"]) == 0: + raise ValueError( + f"One annotation of image {image_id} contains empty 'bbox' value! " + "This json does not have valid COCO format." + ) + is_crowd = obj.get("iscrowd", 0) + if is_crowd == 1: + continue + + segm = anno.get("segmentation", None) + if segm is not None and task == Task.INSTANCE_SEGMENTATION: # either list[list[float]] or dict(RLE) + if isinstance(segm, dict): + if isinstance(segm["counts"], list): + # convert to compressed RLE + segm = mask_util.frPyObjects(segm, *segm["size"]) + else: + print("What happens here?") + else: + # filter out invalid polygons (< 3 points) + segm = [poly for poly in segm if len(poly) % 2 == 0 and len(poly) >= 6] + if len(segm) == 0: + num_instances_without_valid_segmentation += 1 + continue # ignore this instance + obj["segmentation"] = segm + + obj["bbox_mode"] = BoxMode.XYWH_ABS + if id_map: + annotation_category_id = obj["category_id"] + try: + obj["category_id"] = id_map[annotation_category_id] + except KeyError as e: + raise KeyError( + f"Encountered category_id={annotation_category_id} " + "but this id does not exist in 'categories' of the json file." + ) from e + + objs.append(obj) + record["annotations"] = objs + dataset_dicts.append(DetectronDict(**record)) + + metadata = DatasetMetadata( + num_classes=len(thing_classes), + thing_classes=thing_classes, + task=task, + count=len(dataset_dicts), + name=Path(ds_dir).parent.stem, + image_root=ds_dir, + thing_dataset_id_to_contiguous_id=id_map, + json_file=json_file, + ) + + return cls(dicts=dataset_dicts, task=task, metadata=metadata) + + @classmethod + def from_segmentation(cls, ds_dir: str, task: Task, serialize: bool = True): + """ + ds_dir is up to the split. + root/ + test/ + .. + valid/ + .. + train/ + annotations.json + your_format_here/ + + JSON FORMAT: + { + "images": [ + { + "id": 0, + "file_name": "im0.jpeg", + "height": 1024, + "width": 1024 + }, + ], + "annotations": [ + { + "image_id": 0, + "file_name": "im0.png", + } + ] + "categories": [ + { + "id": 0, + "name": "custom_class", + "color": "none", [optional] + "is_thing": True, [optional] + }, + ] + } + """ + logger = get_logger(__name__) + + with open(os.path.join(ds_dir, "annotations.json")) as f: + json_info = json.load(f) + + images = dict() + for info in json_info["images"]: + images[info["id"]] = info["file_name"] + + dataset_dicts = [] + for ann in json_info["annotations"]: + image_id = ann["image_id"] + + image_file = os.path.join(ds_dir, images[image_id]) + label_file = os.path.join(ds_dir, ann["file_name"]) + + dataset_dicts.append(DetectronDict(file_name=image_file, sem_seg_file_name=label_file, image_id=image_id)) + + logger.info("Loaded {} images with semantic segmentation from {}".format(len(dataset_dicts), ds_dir)) + + # This is only useful for metadata + categories = json_info["categories"] + # All the classes are stuff, only a subset is thing + stuff_dataset_id_to_contiguous_id = {} + + for i, cat in enumerate(categories): + stuff_dataset_id_to_contiguous_id[cat["id"]] = i + + # Create dataset metadata + metadata = DatasetMetadata( + num_classes=len(categories), + stuff_classes=[k["name"] for k in categories], + _stuff_colors=[k["color"] for k in categories], + stuff_dataset_id_to_contiguous_id=stuff_dataset_id_to_contiguous_id, + task=Task.SEMSEG, + count=len(dataset_dicts), + name=Path(ds_dir).name, + image_root=ds_dir, + ignore_label=255, + ) + + return cls(dicts=dataset_dicts, task=Task.SEMSEG, metadata=metadata, serialize=serialize) + + @classmethod + def from_roboflow_seg(cls, ds_dir: str, task: Task): + """ + root/ + test/ + .. + valid/ + .. + train/ + _classes.csv + im0.jpeg + im0_mask.png + """ + im_files = [] + + for im in list_files_with_extensions(base_dir=ds_dir, extensions=["jpg", "jpeg", "png"]): + im = str(im) + if not im.endswith("_mask.png"): + im_files.append(im) + + classes = [] + + cls_path = os.path.join(ds_dir, "_classes.csv") + + with open(cls_path, newline="") as csv_file: + reader = csv.reader(csv_file, delimiter=",") + next(reader, None) # skip the headers + for row in reader: + classes.append(row[1].strip()) + dicts = [] + im_files.sort() + + for im in im_files: + mask = im.replace(".jpg", "_mask.png") + if not os.path.exists(mask): + raise ValueError(f"Mask file {mask} does not exist") + if Path(im).stem != Path(mask).stem.replace("_mask", ""): + raise ValueError(f" {Path(im).stem} and {Path(mask).stem.replace('_mask', '')} mismatch") + dicts.append(DetectronDict(file_name=im, sem_seg_file_name=mask)) + + metadata = DatasetMetadata( + stuff_classes=classes, + num_classes=len(classes), + task=task, + name=Path(ds_dir).parent.stem, + count=len(im_files), + image_root=ds_dir, + ignore_label=255, + ) + + return cls(dicts=dicts, task=task, metadata=metadata) + + def split(self, ratio: float, shuffle: bool = True, seed: int = 42) -> Tuple["DictDataset", "DictDataset"]: + random.seed(seed) + _dicts = copy(self.dicts) + + if shuffle: + random.shuffle(_dicts) # type: ignore + split_idx = int(len(_dicts) * ratio) + split1 = _dicts[:split_idx] + meta1 = DatasetMetadata( + num_classes=self.metadata.num_classes, + task=self.metadata.task, + count=len(split1), + thing_classes=self.metadata.thing_classes, + stuff_classes=self.metadata.stuff_classes, + ) + + split2 = _dicts[split_idx:] + meta2 = DatasetMetadata( + num_classes=self.metadata.num_classes, + task=self.metadata.task, + count=len(split2), + thing_classes=self.metadata.thing_classes, + stuff_classes=self.metadata.stuff_classes, + ) + + return DictDataset(dicts=split1, task=self.metadata.task, metadata=meta1), DictDataset( + dicts=split2, task=self.metadata.task, metadata=meta2 + ) + + def merge(self, other: "DictDataset") -> "DictDataset": + assert self.metadata.task == other.metadata.task, "Tasks must match" + assert not self.serialize and not other.serialize, "Serializations must be disabled" + return DictDataset(dicts=self.dicts + other.dicts, task=self.metadata.task, metadata=self.metadata) + + def __str__(self): + return f"DictDataset(task={self.metadata.task}, num_classes={self.metadata.num_classes}, count={self.metadata.count})" + + def __repr__(self): + return self.__str__() diff --git a/focoos/data/datasets/map_dataset.py b/focoos/data/datasets/map_dataset.py new file mode 100644 index 00000000..c2fee572 --- /dev/null +++ b/focoos/data/datasets/map_dataset.py @@ -0,0 +1,129 @@ +import random + +import numpy as np +import supervision as sv +import torch.utils.data as data +from PIL import Image + +from focoos.data.datasets.dict_dataset import DictDataset +from focoos.data.mappers.mapper import DatasetMapper +from focoos.data.transforms import augmentation as A +from focoos.ports import Task +from focoos.utils.logger import get_logger + + +class MapDataset(data.Dataset): + """ + Map a function over the elements in a dataset. + """ + + def __init__(self, dataset: DictDataset, mapper: DatasetMapper): + """ + Args: + dataset: a dataset where map function is applied. Can be either + map-style or iterable dataset. When given an iterable dataset, + the returned object will also be an iterable dataset. + map_func: a callable which maps the element in dataset. map_func can + return None to skip the data (e.g. in case of errors). + How None is handled depends on the style of `dataset`. + If `dataset` is map-style, it randomly tries other elements. + If `dataset` is iterable, it skips the data and tries the next. + """ + self.dataset = dataset + self.mapper = mapper # wrap so that a lambda will work + self.logger = get_logger(__name__) + + self._rng = random.Random(42) + self._fallback_candidates = set(range(len(dataset))) + + def __getnewargs__(self): + return self.dataset, self.mapper + + def __len__(self): + return len(self.dataset) + + def __getitem__(self, idx): + retry_count = 0 + cur_idx = int(idx) + + while True: + try: + data = self.mapper(self.dataset[cur_idx]) + except Exception as e: + self.logger.warning(f"Error mapping item {cur_idx}: {e}") + data = None + if retry_count >= 10: + raise e + + if data is not None and (data.instances is None or len(data.instances) > 0): + # if it has annotations, it must more than 1 instance, otherwise it is not a valid training data + self._fallback_candidates.add(cur_idx) + return data + + # _map_func fails for this idx, use a random new index from the pool + retry_count += 1 + self._fallback_candidates.discard(cur_idx) + cur_idx = self._rng.sample(sorted(self._fallback_candidates), k=1)[0] + + if retry_count >= 3: + self.logger.warning("Failed to apply `_map_func` for idx: {}, retry count: {}".format(idx, retry_count)) + + def preview(self, index=None, use_augmentations=True): + if not use_augmentations: + current_augmentations = self.mapper.augmentations + self.mapper.augmentations = A.AugmentationList([]) + + index = index or random.randint(0, len(self.dataset)) + task = self.dataset.metadata.task + classes = self.dataset.metadata.classes + + label_annotator = sv.LabelAnnotator(text_padding=10, border_radius=10) + box_annotator = sv.BoxAnnotator() + mask_annotator = sv.MaskAnnotator() + sample = self[index] + + im = np.array(sample.image).transpose(1, 2, 0) + + num_samples = sample["instances"].classes.shape[0] + if task in [Task.DETECTION, Task.INSTANCE_SEGMENTATION]: + xyxy = sample["instances"].boxes.tensor.numpy() + else: + xyxy = np.zeros((num_samples, 4)) + + if task in [Task.SEMSEG, Task.INSTANCE_SEGMENTATION]: + masks = sample["instances"].masks.tensor.numpy() + else: + masks = None + + sv_detections = sv.Detections( + xyxy=xyxy, + class_id=sample["instances"].classes.numpy(), + confidence=np.ones_like(sample["instances"].classes.numpy()), + mask=masks, + ) + + if len(sv_detections.xyxy) == 0: + print("No detections found, skipping annotation") + return Image.fromarray(im) + + if task == Task.DETECTION: + annotated_im = box_annotator.annotate(scene=im.copy(), detections=sv_detections) + + elif task in [ + Task.SEMSEG, + Task.INSTANCE_SEGMENTATION, + ]: + annotated_im = mask_annotator.annotate(scene=im.copy(), detections=sv_detections) + + # Fixme: get the classes from the detections + if classes is not None: + labels = [ + f"{classes[int(class_id)] if classes is not None else str(class_id)}" + for class_id in sv_detections.class_id # type: ignore + ] + annotated_im = label_annotator.annotate(scene=annotated_im, detections=sv_detections, labels=labels) + + if not use_augmentations: + self.mapper.augmentations = current_augmentations + + return Image.fromarray(annotated_im) diff --git a/focoos/data/datasets/serialize.py b/focoos/data/datasets/serialize.py new file mode 100644 index 00000000..70da7dcd --- /dev/null +++ b/focoos/data/datasets/serialize.py @@ -0,0 +1,31 @@ +import pickle + +import numpy as np +import torch + +from focoos.utils.logger import get_logger + +logger = get_logger(__name__) + + +class TorchSerializedDataset: + def __init__(self, lst: list): + def _serialize(data): + buffer = pickle.dumps(data, protocol=-1) + return np.frombuffer(buffer, dtype=np.uint8) + + logger.debug("Serializing {} elements to byte tensors and concatenating them all ...".format(len(lst))) + _lst = [_serialize(x) for x in lst] + self._addr = np.asarray([len(x) for x in _lst], dtype=np.int64) + self._addr = torch.from_numpy(np.cumsum(self._addr)) + self._lst = torch.from_numpy(np.concatenate(_lst)) + logger.debug("Serialized dataset takes {:.2f} MiB".format(len(self._lst) / 1024**2)) + + def __len__(self): + return len(self._addr) + + def __getitem__(self, idx): + start_addr = 0 if idx == 0 else self._addr[idx - 1].item() + end_addr = self._addr[idx].item() + bytes = memoryview(self._lst[start_addr:end_addr].numpy()) + return pickle.loads(bytes) diff --git a/focoos/data/default_aug.py b/focoos/data/default_aug.py new file mode 100644 index 00000000..f969a9f8 --- /dev/null +++ b/focoos/data/default_aug.py @@ -0,0 +1,267 @@ +import copy +import sys +from dataclasses import dataclass +from typing import List, Optional, Tuple + +from focoos.data.transforms import augmentation as A +from focoos.data.transforms import transform as T +from focoos.ports import Task +from focoos.utils.logger import get_logger + +logger = get_logger(__name__) + + +@dataclass +class DatasetAugmentations: + """ + Configuration class for dataset augmentations. + + This class defines parameters for various image transformations used in training and validation + pipelines for computer vision tasks. It provides a comprehensive set of options for both + color and geometric augmentations. + + Attributes: + resolution (int): Target image size for resizing operations. + Range [256, 1024]. Default: 640. + == + color_augmentation (float): Strenght of color augmentations. + Range [0,1]. Default: 0.0. + == + horizontal_flip (float): Probability of applying horizontal flip. + Range [0,1]. Default: 0.0. + vertical_flip (float): Probability of applying vertical flip. + Range [0,1]. Default: 0.0. + zoom_out (float): Probability of applying RandomZoomOut. + Range [0,1]. Default: 0.0. + zoom_out_side (float): Zoom out side range. + Range [1,5]. Default: 4.0. + rotation (float): Probability of applying RandomRotation. 1 equals +/-180 degrees. + Range [0,1]. Default: 0.0. + == + square (bool): Whether to Square the image. + Default: False. + aspect_ratio (float): Aspect ratio for resizing (actual scale range is (2 ** -aspect_ratio, 2 ** aspect_ratio). + Range [0,1]. Default: 0.0. + scale_ratio (Optional[float]): scale factor for resizing (actual scale range is (2 ** -scale_ratio, 2 ** scale_ratio). + Range [0,1]. Default: None. + max_size (Optional[int]): Maximum allowed dimension after resizing. + Range [256, sys.maxsize]. Default: sys.maxsize. + == + crop (bool): Whether to apply RandomCrop. + Default: False. + crop_size_min (Optional[int]): Minimum crop size for RandomCrop. + Range [256, 1024]. Default: None. + crop_size_max (Optional[int]): Maximum crop size for RandomCrop. + Range [256, 1024]. Default: None. + """ + + # Resolution for resizing + resolution: int = 640 + + # Color augmentation parameters + color_augmentation: float = 0.0 + color_base_brightness: int = 32 + color_base_saturation: float = 0.5 + color_base_contrast: float = 0.5 + color_base_hue: float = 18 + # blur: float = 0.0 + # noise: float = 0.0 + + # Geometric augmentation + horizontal_flip: float = 0.0 + vertical_flip: float = 0.0 + zoom_out: float = 0.0 + zoom_out_side: float = 4.0 + rotation: float = 0.0 + aspect_ratio: float = 0.0 + + ## Rescaling + square: float = 0.0 + scale_ratio: float = 0.0 + max_size: int = 4096 + + # Cropping + crop: bool = False + crop_size: Optional[int] = None + + # TODO: Add more augmentations like: + # - GaussianBlur + # - RandomNoise + # - RandomResizedCrop + + def override(self, args): + if not isinstance(args, dict): + args = vars(args) + for key, value in args.items(): + if hasattr(self, key) and value is not None: + setattr(self, key, value) + return self + + def get_augmentations(self, img_format="RGB", task: Optional[Task] = None) -> List[T.Transform]: + """Generate augmentation pipeline based on configuration.""" + augs = [] + self.max_size = self.max_size if self.max_size else sys.maxsize + + ### Add color augmentation if configured + if self.color_augmentation > 0: + brightness_delta = int(self.color_base_brightness * self.color_augmentation) + contrast_delta = self.color_base_contrast * self.color_augmentation + saturation_delta = self.color_base_saturation * self.color_augmentation + hue_delta = int(self.color_base_hue * self.color_augmentation) + augs.append( + T.ColorAugSSDTransform( + img_format=img_format, + brightness_delta=brightness_delta, + contrast_low=(1 - contrast_delta), + contrast_high=(1 + contrast_delta), + saturation_low=(1 - saturation_delta), + saturation_high=(1 + saturation_delta), + hue_delta=hue_delta, + ), + ) + + ### Add geometric augmentations + # Add flipping augmentations if configured + if self.horizontal_flip > 0: + augs.append(A.RandomFlip(prob=self.horizontal_flip, horizontal=True)) + if self.vertical_flip > 0: + augs.append(A.RandomFlip(prob=self.vertical_flip, horizontal=False, vertical=True)) + + # Add zoom out augmentations if configured + if self.zoom_out > 0.0: + seg_pad_value = 255 if task == Task.SEMSEG else 0 + augs.append( + A.RandomApply( + A.RandomZoomOut(side_range=(1.0, self.zoom_out_side), pad_value=0, seg_pad_value=seg_pad_value), + prob=self.zoom_out, + ) + ) + + ### Add AspectRatio augmentations based on configuration + if self.square > 0.0: + augs.append(A.RandomApply(A.Resize(shape=(self.resolution, self.resolution)), prob=self.square)) + elif self.aspect_ratio > 0.0: + augs.append(A.RandomAspectRatio(aspect_ratio=self.aspect_ratio)) + + ### Add Resizing augmentations based on configuration + min_scale, max_scale = 2 ** (-self.scale_ratio), 2**self.scale_ratio + augs.append( + A.ResizeShortestEdge( + short_edge_length=[int(x * self.resolution) for x in [min_scale, max_scale]], + sample_style="range", + max_size=self.max_size, + ) + ) + + ### Add rotation augmentations if configured + if self.rotation > 0: + angle = self.rotation * 180 + augs.append(A.RandomRotation(angle=(-angle, angle), expand=False)) + + # Add cropping if configured + if self.crop: + crop_range = (self.crop_size or self.resolution, self.crop_size or self.resolution) + augs.append(A.RandomCrop(crop_type="absolute_range", crop_size=crop_range)) + + return augs + + +fai_instance_train_augs = DatasetAugmentations( + resolution=1024, + crop=True, + scale_ratio=1.0, # 0.5, 2 + max_size=2048, + horizontal_flip=0.5, + color_augmentation=1.0, +) + +fai_segmentation_train_augs = DatasetAugmentations( + resolution=640, + crop=True, + scale_ratio=1.0, # 0.5, 2 + max_size=2048, + color_augmentation=1.0, + horizontal_flip=0.5, +) + +fai_detection_train_augs = DatasetAugmentations( + resolution=640, + color_augmentation=1.0, + horizontal_flip=0.5, + aspect_ratio=0.5, # 0.7, 1.4 + zoom_out=0.5, + zoom_out_side=4.0, + square=1.0, + scale_ratio=0.5, # 0.7, 1.4 +) + +detection_train_augs = DatasetAugmentations( + resolution=640, + square=1.0, + max_size=int(640 * 1.25), + crop=True, + scale_ratio=0.5, # 0.7, 1.4 + color_augmentation=1.0, + horizontal_flip=0.5, +) + +segmentation_train_augs = DatasetAugmentations( + resolution=640, + crop=True, + scale_ratio=0.5, # 0.7, 1.4 + color_augmentation=1.0, + horizontal_flip=0.5, +) + + +detection_val_augs = DatasetAugmentations( + resolution=640, + square=1.0, +) + +segmentation_val_augs = DatasetAugmentations( + resolution=640, +) + +classification_train_augs = DatasetAugmentations( + resolution=224, + scale_ratio=0.5, + crop=True, + color_augmentation=1.0, + horizontal_flip=0.5, +) + +classification_val_augs = DatasetAugmentations( + resolution=224, +) + + +def get_default_by_task( + task: Task, resolution: int = 640, advanced: bool = False +) -> Tuple[DatasetAugmentations, DatasetAugmentations]: + if task == Task.DETECTION: + train, val = ( + detection_train_augs if not advanced else fai_detection_train_augs, + detection_val_augs if not advanced else detection_val_augs, + ) + elif task == Task.SEMSEG: # or task == Task.PANSEG: + train, val = ( + segmentation_train_augs if not advanced else fai_segmentation_train_augs, + segmentation_val_augs if not advanced else segmentation_val_augs, + ) + elif task == Task.INSTANCE_SEGMENTATION: + train, val = ( + segmentation_train_augs if not advanced else fai_instance_train_augs, + segmentation_val_augs if not advanced else segmentation_val_augs, + ) + elif task == Task.CLASSIFICATION: + train, val = ( + classification_train_augs, + classification_val_augs, + ) + else: + raise ValueError(f"Invalid task: {task}") + + train.resolution = resolution + val.resolution = resolution + return copy.deepcopy(train), copy.deepcopy(val) diff --git a/focoos/data/loaders.py b/focoos/data/loaders.py new file mode 100644 index 00000000..476fa8bd --- /dev/null +++ b/focoos/data/loaders.py @@ -0,0 +1,175 @@ +import operator +from typing import Union + +import torch +import torch.utils.data as torchdata + +from focoos.utils.distributed import comm +from focoos.utils.env import seed_all_rng +from focoos.utils.logger import get_logger + +from .datasets.common import AspectRatioGroupedDataset, ToIterableDataset +from .datasets.map_dataset import MapDataset +from .samplers import InferenceSampler, TrainingSampler + + +def worker_init_reset_seed(worker_id): + initial_seed = torch.initial_seed() % 2**31 + seed_all_rng(initial_seed + worker_id) + + +def trivial_batch_collator(batch): + """ + A batch collator that does nothing. + """ + return batch + + +def build_batch_data_loader( + dataset, + sampler, + total_batch_size, + *, + aspect_ratio_grouping=False, + num_workers=0, + prefetch_factor=2, + collate_fn=None, + drop_last: bool = True, + **kwargs, +) -> Union[torchdata.DataLoader, AspectRatioGroupedDataset]: + """ + Build a batched dataloader. The main differences from `torch.utils.data.DataLoader` are: + 1. support aspect ratio grouping options + 2. use no "batch collation", because this is common for detection training + + Args: + dataset (torch.utils.data.Dataset): a pytorch map-style or iterable dataset. + sampler (torch.utils.data.sampler.Sampler or None): a sampler that produces indices. + Must be provided iff. ``dataset`` is a map-style dataset. + total_batch_size, aspect_ratio_grouping, num_workers, collate_fn: see + :func:`build_detection_train_loader`. + single_gpu_batch_size: You can specify either `single_gpu_batch_size` or `total_batch_size`. + `single_gpu_batch_size` specifies the batch size that will be used for each gpu/process. + `total_batch_size` allows you to specify the total aggregate batch size across gpus. + It is an error to supply a value for both. + drop_last (bool): if ``True``, the dataloader will drop incomplete batches. + + Returns: + iterable[list]. Length of each list is the batch size of the current + GPU. Each element in the list comes from the dataset. + """ + world_size = comm.get_world_size() + assert total_batch_size > 0 and total_batch_size % world_size == 0, ( + "Total batch size ({}) must be divisible by the number of gpus ({}).".format(total_batch_size, world_size) + ) + batch_size = total_batch_size // world_size + logger = get_logger(__name__) + logger.info("Making batched data loader with batch_size=%d", batch_size) + + dataset = ToIterableDataset(dataset, sampler, shard_chunk_size=batch_size) + + if aspect_ratio_grouping: + assert drop_last, "Aspect ratio grouping will drop incomplete batches." + data_loader = torchdata.DataLoader( + dataset, + num_workers=num_workers, + collate_fn=operator.itemgetter(0), # don't batch, but yield individual elements + worker_init_fn=worker_init_reset_seed, + prefetch_factor=prefetch_factor, + ) # yield individual mapped dict + data_loader = AspectRatioGroupedDataset(data_loader, batch_size) + return data_loader + else: + return torchdata.DataLoader( + dataset, + batch_size=batch_size, + drop_last=drop_last, + num_workers=num_workers, + collate_fn=trivial_batch_collator if collate_fn is None else collate_fn, + worker_init_fn=worker_init_reset_seed, + prefetch_factor=prefetch_factor, + ) + + +def build_detection_train_loader( + dataset: MapDataset, + *, + total_batch_size, + aspect_ratio_grouping=True, + num_workers=0, + collate_fn=None, +) -> Union[torchdata.DataLoader, AspectRatioGroupedDataset]: + """ + Build a dataloader for object detection with some default features. + + Args: + dataset (MapDataset): a MapDataset, + total_batch_size (int): total batch size across all workers. + aspect_ratio_grouping (bool): whether to group images with similar + aspect ratio for efficiency. When enabled, it requires each + element in dataset be a dict with keys "width" and "height". + num_workers (int): number of parallel data loading workers + collate_fn: a function that determines how to do batching, same as the argument of + `torch.utils.data.DataLoader`. Defaults to do no collation and return a list of + data. No collation is OK for small batch size and simple data structures. + If your batch size is large and each sample contains too many small tensors, + it's more efficient to collate them in data loader. + + Returns: + torch.utils.data.DataLoader: + a dataloader. Each output from it is a ``list[mapped_element]`` of length + ``total_batch_size / num_workers``, where ``mapped_element`` is produced + by the ``mapper``. + """ + + return build_batch_data_loader( + dataset=dataset, + sampler=TrainingSampler(len(dataset)), + total_batch_size=total_batch_size, + aspect_ratio_grouping=aspect_ratio_grouping, + num_workers=num_workers, + collate_fn=collate_fn, + ) + + +def build_detection_test_loader( + dataset: MapDataset, + *, + batch_size: int = 1, + num_workers: int = 0, + collate_fn=None, +) -> torchdata.DataLoader: + """ + Similar to `build_detection_train_loader`, with default batch size = 1, + and sampler = :class:`InferenceSampler`. This sampler coordinates all workers + to produce the exact set of all samples. + + Args: + dataset (MapDataset): a MapDataset. + batch_size (int): the batch size of the data loader to be created. + Default to 1 image per worker since this is the standard when reporting + inference time in papers. + num_workers (int): number of parallel data loading workers + collate_fn: same as the argument of `torch.utils.data.DataLoader`. + Defaults to do no collation and return a list of data. + + Returns: + torch.utils.data.DataLoader: a torch DataLoader, that loads the given detection + dataset, with test-time transformation and batching. + + Examples: + :: + data_loader = build_detection_test_loader(dataset, batch_size=1, num_workers=4) + + # or, with custom collate function: + data_loader = build_detection_test_loader(dataset, batch_size=2, num_workers=2, collate_fn=my_custom_collate_fn) + """ + + return torchdata.DataLoader( + dataset, + batch_size=batch_size, + drop_last=False, + sampler=InferenceSampler(len(dataset)), + num_workers=num_workers, + collate_fn=trivial_batch_collator if collate_fn is None else collate_fn, + ) diff --git a/focoos/data/mappers/__init__.py b/focoos/data/mappers/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/focoos/data/mappers/classification_dataset_mapper.py b/focoos/data/mappers/classification_dataset_mapper.py new file mode 100644 index 00000000..323e7e75 --- /dev/null +++ b/focoos/data/mappers/classification_dataset_mapper.py @@ -0,0 +1,97 @@ +import copy +from dataclasses import dataclass +from typing import Optional, Sequence, Union + +import numpy as np +import torch + +from focoos.data import utils +from focoos.data.mappers.mapper import DatasetMapper +from focoos.data.transforms import augmentation as A +from focoos.data.transforms import transform as T +from focoos.ports import DatasetEntry +from focoos.utils.logger import get_logger + + +@dataclass +class ClassificationDatasetDict(DatasetEntry): + """ + Dataset dictionary for classification tasks. + Extends the base DatasetEntry with fields needed for classification. + """ + + label: Optional[int] = None + + +class ClassificationDatasetMapper(DatasetMapper): + """ + A callable which takes a dataset dict in Detectron2/Focoos Dataset format, + and maps it into a format used by classification models. + + It performs the following operations: + 1. Read the image from "file_name" + 2. Apply augmentations to the image + 3. Prepare image tensor and class label + """ + + def __init__( + self, + is_train: bool, + *, + augmentations: Sequence[Union[A.Augmentation, T.Transform]], + image_format: str = "RGB", + ): + """ + Args: + is_train: Whether it's used in training or inference + augmentations: A list of augmentations or transforms to apply + image_format: An image format supported by PIL and OpenCV + """ + super().__init__( + is_train=is_train, + augmentations=augmentations, # type: ignore + image_format=image_format, + ) + self.logger = get_logger(__name__) + mode = "training" if is_train else "inference" + self.logger.info(f"[ClassificationDatasetMapper] Augmentations used in {mode}: {augmentations}") + + def __call__(self, dataset_dict: dict) -> ClassificationDatasetDict: + """ + Args: + dataset_dict (dict): Metadata of one image, in Detectron2/Focoos Dataset format. + + Returns: + ClassificationDatasetDict: A format that contains the image and label + """ + dataset_dict = copy.deepcopy(dataset_dict) # It will be modified by code below + + # Read image + image = utils.read_image(dataset_dict["file_name"], format=self.image_format) + self.check_image_size(dataset_dict, image) + + # Extract class label from annotations + label = None + if "annotations" in dataset_dict and len(dataset_dict["annotations"]) > 0: + # For classification, we take the first annotation's category_id as the label + label = dataset_dict["annotations"][0].get("category_id", None) + + # Apply augmentations + aug_input = A.AugInput(image) + self.augmentations(aug_input) # apply augmentations in place, no need to return + image = aug_input.image + if image is None: + raise ValueError(f"Image is None for {dataset_dict['file_name']}") + + # Convert image to tensor format (C, H, W) + dataset_dict["image"] = torch.as_tensor(np.ascontiguousarray(image.transpose(2, 0, 1))) + + # Create the return object + return ClassificationDatasetDict( + image=dataset_dict["image"], + height=dataset_dict["height"], + width=dataset_dict["width"], + file_name=dataset_dict["file_name"], + image_id=dataset_dict.get("image_id", None), + label=label, + ) diff --git a/focoos/data/mappers/detection_dataset_mapper.py b/focoos/data/mappers/detection_dataset_mapper.py new file mode 100644 index 00000000..5bf7373f --- /dev/null +++ b/focoos/data/mappers/detection_dataset_mapper.py @@ -0,0 +1,196 @@ +# Copyright (c) Facebook, Inc. and its affiliates. +# Modified by Bowen Cheng from https://github.com/facebookresearch/detr/blob/master/d2/detr/dataset_mapper.py +import copy +from typing import List, Optional, Sequence, Union + +import numpy as np +import torch + +from focoos.data import utils +from focoos.data.transforms import augmentation as A +from focoos.data.transforms import transform as T +from focoos.ports import DatasetEntry +from focoos.structures import BitMasks, BoxMode, Instances +from focoos.utils.logger import get_logger + +from .mapper import DatasetMapper + + +class DetectionDatasetMapper(DatasetMapper): + """ + A callable which takes a dataset dict in Detectron2 Dataset format, + and map it into a format used by the model. + + This is the default callable to be used to map your dataset dict into training data. + You may need to follow it to implement your own one for customized logic, + such as a different way to read or transform images. + See :doc:`/tutorials/data_loading` for details. + + The callable currently does the following: + + 1. Read the image from "file_name" + 2. Applies cropping/geometric transforms to the image and annotations + 3. Prepare data and annotations to Tensor and :class:`Instances` + """ + + def __init__( + self, + is_train: bool, + *, + augmentations: Sequence[Union[A.Augmentation, T.Transform]], + image_format: str, + use_instance_mask: bool = False, + use_keypoint: bool = False, + keypoint_hflip_indices: Optional[np.ndarray] = None, + recompute_boxes: bool = False, + ): + """ + Args: + is_train: whether it's used in training or inference + augmentations: a list of augmentations or deterministic transforms to apply + image_format: an image format supported by :func:`detection_utils.read_image`. + use_instance_mask: whether to process instance segmentation annotations, if available + use_keypoint: whether to process keypoint annotations if available + keypoint_hflip_indices: see :func:`detection_utils.create_keypoint_hflip_indices` + precomputed_proposal_topk: if given, will load pre-computed + proposals from dataset_dict and keep the top k proposals for each image. + recompute_boxes: whether to overwrite bounding box annotations + by computing tight bounding boxes from instance mask annotations. + """ + if recompute_boxes: + assert use_instance_mask, "recompute_boxes requires instance masks" + # fmt: off + self.is_train = is_train + self.augmentations = A.AugmentationList(augmentations) + self.image_format = image_format + self.use_instance_mask = use_instance_mask + self.use_keypoint = use_keypoint + self.keypoint_hflip_indices = keypoint_hflip_indices + self.recompute_boxes = recompute_boxes + # fmt: on + logger = get_logger(__name__) + mode = "training" if is_train else "inference" + logger.info(f"[DatasetMapper] Augmentations used in {mode}: {augmentations}") + + def _transform_annotations(self, dataset_dict, transforms, image_shape): + # USER: Modify this if you want to keep them for some reason. + for anno in dataset_dict["annotations"]: + if not self.use_instance_mask: + anno.pop("segmentation", None) + if not self.use_keypoint: + anno.pop("keypoints", None) + + use_bbox = len(dataset_dict["annotations"]) > 0 and "bbox" in dataset_dict["annotations"][0] + use_mask = len(dataset_dict["annotations"]) > 0 and "segmentation" in dataset_dict["annotations"][0] + use_bbox = True if not (use_mask or use_bbox) else use_bbox + + # USER: Implement additional transformations if you have other types of data + annos = [ + utils.transform_instance_annotations( + obj, + transforms, + image_shape, + keypoint_hflip_indices=self.keypoint_hflip_indices, + ) + for obj in dataset_dict.pop("annotations") + if obj.get("iscrowd", 0) == 0 + ] + + instances: Instances = utils.annotations_to_instances(annos, image_shape) + # After transforms such as cropping are applied, the bounding box may no longer + # tightly bound the object. As an example, imagine a triangle object + # [(0,0), (2,0), (0,2)] cropped by a box [(1,0),(2,2)] (XYXY format). The tight + # bounding box of the cropped triangle should be [(1,0),(2,1)], which is not equal to + # the intersection of original bounding box and the cropping box. + if self.recompute_boxes and self.use_instance_mask: + assert isinstance(instances.masks, BitMasks), "Error, masks in instances are not BitMasks" + instances.boxes = instances.masks.get_bounding_boxes() + + instances = utils.filter_empty_instances(instances, by_box=use_bbox, by_mask=use_mask) + + if self.use_instance_mask: + h, w = instances.image_size + if instances.masks is not None: # Handle Images without annotations + instances.masks = instances.masks + else: + instances.masks = BitMasks(torch.zeros(0, h, w)) + + dataset_dict["instances"] = instances + + def __call__(self, dataset_dict: dict) -> DatasetEntry: + """ + Args: + dataset_dict (dict): Metadata of one image, in Detectron2 Dataset format. + + Returns: + DatasetEntry: a format that builtin models accept + """ + dataset_dict = copy.deepcopy(dataset_dict) # it will be modified by code below + # USER: Write your own image loading if it's not from a file + image = utils.read_image(dataset_dict["file_name"], format=self.image_format) + self.check_image_size(dataset_dict, image) + + if "annotations" in dataset_dict: + # filter crowd annotations + annotations = [obj for obj in dataset_dict["annotations"] if obj.get("iscrowd", 0) == 0] + if len(annotations) > 0 and "bbox" in annotations[0]: + boxes = [] + for annotation in annotations: + boxes.append( + BoxMode.convert( + annotation["bbox"], + annotation["bbox_mode"], + BoxMode.XYXY_ABS, + ) + ) + # clip transformed bbox to image size + boxes = np.array([boxes])[0].clip(min=0) + else: + boxes = None + else: + annotations = None + boxes = None + + # we don't augment the boxes if we are in inference mode + aug_input = A.AugInput(image, boxes=boxes) + transforms = self.augmentations(aug_input) + # we don't collect boxes but we recompute the transforms at the end + image = aug_input.image + if image is None: + raise ValueError(f"Image is None for {dataset_dict['file_name']}") + image_shape = image.shape[:2] # h, w + # Pytorch's dataloader is efficient on torch.Tensor due to shared-memory, + # but not efficient on large generic data structures due to the use of pickle & mp.Queue. + # Therefore it's important to use torch.Tensor. + dataset_dict["image"] = torch.as_tensor(np.ascontiguousarray(image.transpose(2, 0, 1))) + + if "annotations" in dataset_dict: + # there is a problem here with image_shape (annotations are not transformed) + self._transform_annotations(dataset_dict, transforms, image_shape) + + return DatasetEntry( + image=dataset_dict["image"], + height=dataset_dict["height"], + width=dataset_dict["width"], + file_name=dataset_dict["file_name"], + image_id=dataset_dict["image_id"], + instances=dataset_dict.get("instances", None), + ) + + +class InstanceDatasetMapper(DetectionDatasetMapper): + def __init__( + self, + is_train: bool, + *, + augmentations: List[Union[A.Augmentation, T.Transform]], + image_format: str, + recompute_boxes: bool = False, + ): + super().__init__( + is_train, + augmentations=augmentations, + image_format=image_format, + use_instance_mask=True, + recompute_boxes=recompute_boxes, + ) diff --git a/focoos/data/mappers/mapper.py b/focoos/data/mappers/mapper.py new file mode 100644 index 00000000..a0ccea11 --- /dev/null +++ b/focoos/data/mappers/mapper.py @@ -0,0 +1,45 @@ +import logging +from typing import List, Union + +from focoos.data.transforms import augmentation as A +from focoos.data.transforms import transform as T +from focoos.ports import DatasetEntry +from focoos.utils.logger import log_first_n + + +class DatasetMapper: + def __init__( + self, + is_train=True, + *, + augmentations: List[Union[A.Augmentation, T.Transform]], + image_format: str, + ): + self.is_train = is_train + self.augmentations = A.AugmentationList(augmentations) + self.image_format = image_format + + def check_image_size(self, dataset_dict, image): + expected_wh = None + if "width" in dataset_dict or "height" in dataset_dict: + expected_wh = (dataset_dict["width"], dataset_dict["height"]) + image_wh = (image.shape[1], image.shape[0]) + if not expected_wh or image_wh != expected_wh: + if expected_wh: + log_first_n( + logging.WARNING, + "Image size is different from the one in the annotations.", + n=1, + ) + dataset_dict["width"] = image.shape[1] + dataset_dict["height"] = image.shape[0] + + def __call__(self, dataset_dic: dict) -> DatasetEntry: + """ + Args: + dataset_dict (DetectronDict): Metadata of one image, in Detectron2 Dataset format. + + Returns: + DatasetEntry: an object containing the image, annotations and metadata + """ + raise NotImplementedError("This is an abstract class, never use DatasetMapper directly") diff --git a/focoos/data/mappers/semantic_dataset_mapper.py b/focoos/data/mappers/semantic_dataset_mapper.py new file mode 100644 index 00000000..b0197420 --- /dev/null +++ b/focoos/data/mappers/semantic_dataset_mapper.py @@ -0,0 +1,137 @@ +# Copyright (c) Facebook, Inc. and its affiliates. +import copy +from dataclasses import dataclass +from typing import Optional + +import numpy as np +import torch + +from focoos.data import utils +from focoos.data.transforms import augmentation as A +from focoos.ports import DatasetEntry +from focoos.structures import BitMasks, Instances +from focoos.utils.logger import get_logger + +from .mapper import DatasetMapper + + +@dataclass +class SemanticSegmentationDatasetEntry(DatasetEntry): + """ + Dataset entry for semantic segmentation evaluation. + """ + + sem_seg_file_name: Optional[str] = None + + +class SemanticDatasetMapper(DatasetMapper): + """ + A callable which takes a dataset dict in Detectron2 Dataset format, + and map it into a format used by MaskFormer for semantic segmentation. + + The callable currently does the following: + + 1. Read the image from "file_name" + 2. Applies geometric transforms to the image and annotation + 3. Find and applies suitable cropping to the image and annotation + 4. Prepare image and annotation to Tensors + """ + + def __init__( + self, + is_train=True, + *, + augmentations, + image_format="RGB", + ignore_label=255, + ): + """ + Args: + is_train: for training or inference + augmentations: a list of augmentations or deterministic transforms to apply + image_format: an image format supported by :func:`detection_utils.read_image`. + ignore_label: the label that is ignored to evaluation + """ + self.is_train = is_train + self.augmentations = augmentations + self.img_format = image_format + self.ignore_label = ignore_label + + logger = get_logger(__name__) + mode = "training" if is_train else "inference" + logger.info(f"[{self.__class__.__name__}] Augmentations used in {mode}: {augmentations}") + + def __call__(self, dataset_dict: dict) -> SemanticSegmentationDatasetEntry: + dataset_dict = copy.deepcopy(dataset_dict) # it will be modified by code below + image = utils.read_image(dataset_dict["file_name"], format=self.img_format) + self.check_image_size(dataset_dict, image) + + if "sem_seg_file_name" in dataset_dict and self.is_train: + # PyTorch transformation not implemented for uint16, so converting it to double first + sem_seg_gt = utils.read_image(dataset_dict.pop("sem_seg_file_name")).astype("double") + else: + sem_seg_gt = None + + if sem_seg_gt is None and self.is_train: + raise ValueError( + "Cannot find 'sem_seg_file_name' for semantic segmentation dataset {}.".format( + dataset_dict["file_name"] + ) + ) + + aug_input = A.AugInput(image, sem_seg=sem_seg_gt) + aug_input, transforms = A.apply_augmentations(self.augmentations, aug_input) + + if not hasattr(aug_input, "image") or aug_input.image is None: # type: ignore + raise ValueError(f"Image is None for {dataset_dict['file_name']}") + image = aug_input.image # type: ignore + sem_seg_gt = aug_input.sem_seg if sem_seg_gt is not None else None # type: ignore + + # Pad image and segmentation label here! + image = torch.as_tensor(np.ascontiguousarray(image.transpose(2, 0, 1))) + if sem_seg_gt is not None: + sem_seg_gt = torch.as_tensor(sem_seg_gt.astype("long")) + + image_shape = (image.shape[-2], image.shape[-1]) # h, w + + # Pytorch's dataloader is efficient on torch.Tensor due to shared-memory, + # but not efficient on large generic data structures due to the use of pickle & mp.Queue. + # Therefore it's important to use torch.Tensor. + dataset_dict["image"] = image + + if sem_seg_gt is not None: + dataset_dict["sem_seg"] = sem_seg_gt.long() + + if "annotations" in dataset_dict: + raise ValueError("Semantic segmentation dataset should not have 'annotations'.") + + # Prepare per-category binary masks + if sem_seg_gt is not None: + sem_seg_gt = sem_seg_gt.numpy() + + classes = np.unique(sem_seg_gt) + # remove ignored region + classes = classes[classes != self.ignore_label] + classes = torch.tensor(classes, dtype=torch.int64) + + masks_np = [] + for class_id in classes: + masks_np.append(sem_seg_gt == class_id) + + if len(masks_np) == 0: + # Some image does not have annotation (all ignored) + masks = BitMasks(torch.zeros((0, sem_seg_gt.shape[-2], sem_seg_gt.shape[-1]))) + else: + masks = BitMasks(torch.stack([x.contiguous() for x in masks_np])) + + dataset_dict["instances"] = Instances(image_shape, classes=classes, masks=masks) + + return SemanticSegmentationDatasetEntry( + image=dataset_dict["image"], + height=dataset_dict["height"], + width=dataset_dict["width"], + file_name=dataset_dict["file_name"], + image_id=dataset_dict["image_id"], + instances=dataset_dict.get("instances", None), + sem_seg_file_name=dataset_dict.get("sem_seg_file_name", None), + ) diff --git a/focoos/data/samplers.py b/focoos/data/samplers.py new file mode 100644 index 00000000..36bb30be --- /dev/null +++ b/focoos/data/samplers.py @@ -0,0 +1,100 @@ +import itertools +from typing import Optional + +import torch +from torch.utils.data.sampler import Sampler + +from focoos.utils.distributed import comm + + +class TrainingSampler(Sampler): + """ + In training, we only care about the "infinite stream" of training data. + So this sampler produces an infinite stream of indices and + all workers cooperate to correctly shuffle the indices and sample different indices. + + The samplers in each worker effectively produces `indices[worker_id::num_workers]` + where `indices` is an infinite stream of indices consisting of + `shuffle(range(size)) + shuffle(range(size)) + ...` (if shuffle is True) + or `range(size) + range(size) + ...` (if shuffle is False) + + Note that this sampler does not shard based on pytorch DataLoader worker id. + A sampler passed to pytorch DataLoader is used only with map-style dataset + and will not be executed inside workers. + But if this sampler is used in a way that it gets execute inside a dataloader + worker, then extra work needs to be done to shard its outputs based on worker id. + This is required so that workers don't produce identical data. + :class:`ToIterableDataset` implements this logic. + This note is true for all samplers in detectron2. + """ + + def __init__(self, size: int, shuffle: bool = True, seed: Optional[int] = None): + """ + Args: + size (int): the total number of data of the underlying dataset to sample from + shuffle (bool): whether to shuffle the indices or not + seed (int): the initial seed of the shuffle. Must be the same + across all workers. If None, will use a random seed shared + among workers (require synchronization among all workers). + """ + if not isinstance(size, int): + raise TypeError(f"TrainingSampler(size=) expects an int. Got type {type(size)}.") + if size <= 0: + raise ValueError(f"TrainingSampler(size=) expects a positive int. Got {size}.") + self._size = size + self._shuffle = shuffle + if seed is None: + seed = comm.shared_random_seed() + self._seed = int(seed) + + self._rank = comm.get_rank() + self._world_size = comm.get_world_size() + + def __iter__(self): + start = self._rank + yield from itertools.islice(self._infinite_indices(), start, None, self._world_size) + + def _infinite_indices(self): + g = torch.Generator() + g.manual_seed(self._seed) + while True: + if self._shuffle: + yield from torch.randperm(self._size, generator=g).tolist() + else: + yield from torch.arange(self._size).tolist() + + +class InferenceSampler(Sampler): + """ + Produce indices for inference across all workers. + Inference needs to run on the __exact__ set of samples, + therefore when the total number of samples is not divisible by the number of workers, + this sampler produces different number of samples on different workers. + """ + + def __init__(self, size: int): + """ + Args: + size (int): the total number of data of the underlying dataset to sample from + """ + self._size = size + assert size > 0 + self._rank = comm.get_rank() + self._world_size = comm.get_world_size() + self._local_indices = self._get_local_indices(size, self._world_size, self._rank) + + @staticmethod + def _get_local_indices(total_size, world_size, rank): + shard_size = total_size // world_size + left = total_size % world_size + shard_sizes = [shard_size + int(r < left) for r in range(world_size)] + + begin = sum(shard_sizes[:rank]) + end = min(sum(shard_sizes[: rank + 1]), total_size) + return range(begin, end) + + def __iter__(self): + yield from self._local_indices + + def __len__(self): + return len(self._local_indices) diff --git a/focoos/data/transforms/augmentation.py b/focoos/data/transforms/augmentation.py new file mode 100644 index 00000000..9b56292f --- /dev/null +++ b/focoos/data/transforms/augmentation.py @@ -0,0 +1,1247 @@ +# Copyright (c) Facebook, Inc. and its affiliates. + +import inspect +import pprint +import sys +from typing import Any, List, Optional, Tuple, Union + +import numpy as np +from fvcore.transforms.transform import ( + BlendTransform, + CropTransform, + HFlipTransform, + NoOpTransform, + PadTransform, + Transform, + TransformList, + VFlipTransform, +) +from numpy import random +from PIL import Image + +from focoos.structures import Boxes, pairwise_iou + +from .transform import ( + ExtentTransform, + ResizeTransform, + RotationTransform, +) + +__all__ = [ + "Augmentation", + "AugmentationList", + "AugInput", + "apply_augmentations", + "FixedSizeCrop", + "RandomApply", + "RandomBrightness", + "RandomContrast", + "RandomCrop", + "RandomExtent", + "RandomFlip", + "RandomSaturation", + "RandomLighting", + "RandomRotation", + "Resize", + "ResizeScale", + "ResizeShortestEdge", + "RandomCrop_CategoryAreaConstraint", + "RandomResize", + "MinIoURandomCrop", + "RandomZoomOut", +] + + +def _check_img_dtype(img): + assert isinstance(img, np.ndarray), "[Augmentation] Needs an numpy array, but got a {}!".format(type(img)) + assert not isinstance(img.dtype, np.integer) or (img.dtype == np.uint8), ( + "[Augmentation] Got image of type {}, use uint8 or floating points instead!".format(img.dtype) + ) + assert img.ndim in [2, 3], img.ndim + + +def _get_aug_input_args(aug, aug_input) -> List[Any]: + """ + Get the arguments to be passed to ``aug.get_transform`` from the input ``aug_input``. + """ + if aug.input_args is None: + # Decide what attributes are needed automatically + prms = list(inspect.signature(aug.get_transform).parameters.items()) + # The default behavior is: if there is one parameter, then its "image" + # (work automatically for majority of use cases, and also avoid BC breaking), + # Otherwise, use the argument names. + if len(prms) == 1: + names = ("image",) + else: + names = [] + for name, prm in prms: + if prm.kind in ( + inspect.Parameter.VAR_POSITIONAL, + inspect.Parameter.VAR_KEYWORD, + ): + raise TypeError( + f""" \ +The default implementation of `{type(aug)}.__call__` does not allow \ +`{type(aug)}.get_transform` to use variable-length arguments (*args, **kwargs)! \ +If arguments are unknown, reimplement `__call__` instead. \ +""" + ) + names.append(name) + aug.input_args = tuple(names) + + args = [] + for f in aug.input_args: + try: + args.append(getattr(aug_input, f)) + except AttributeError as e: + raise AttributeError( + f"{type(aug)}.get_transform needs input attribute '{f}', " + f"but it is not an attribute of {type(aug_input)}!" + ) from e + return args + + +class Augmentation: + """ + Augmentation defines (often random) policies/strategies to generate :class:`Transform` + from data. It is often used for pre-processing of input data. + + A "policy" that generates a :class:`Transform` may, in the most general case, + need arbitrary information from input data in order to determine what transforms + to apply. Therefore, each :class:`Augmentation` instance defines the arguments + needed by its :meth:`get_transform` method. When called with the positional arguments, + the :meth:`get_transform` method executes the policy. + + Note that :class:`Augmentation` defines the policies to create a :class:`Transform`, + but not how to execute the actual transform operations to those data. + Its :meth:`__call__` method will use :meth:`AugInput.transform` to execute the transform. + + The returned `Transform` object is meant to describe deterministic transformation, which means + it can be re-applied on associated data, e.g. the geometry of an image and its segmentation + masks need to be transformed together. + (If such re-application is not needed, then determinism is not a crucial requirement.) + """ + + input_args: Optional[Tuple[str]] = None + """ + Stores the attribute names needed by :meth:`get_transform`, e.g. ``("image", "sem_seg")``. + By default, it is just a tuple of argument names in :meth:`self.get_transform`, which often only + contain "image". As long as the argument name convention is followed, there is no need for + users to touch this attribute. + """ + + def _init(self, params=None): + if params: + for k, v in params.items(): + if k != "self" and not k.startswith("_"): + setattr(self, k, v) + + def get_transform(self, *args) -> Transform: + """ + Execute the policy based on input data, and decide what transform to apply to inputs. + + Args: + args: Any fixed-length positional arguments. By default, the name of the arguments + should exist in the :class:`AugInput` to be used. + + Returns: + Transform: Returns the deterministic transform to apply to the input. + + Examples: + :: + class MyAug: + # if a policy needs to know both image and semantic segmentation + def get_transform(image, sem_seg) -> T.Transform: + pass + + + tfm: Transform = MyAug().get_transform(image, sem_seg) + new_image = tfm.apply_image(image) + + Notes: + Users can freely use arbitrary new argument names in custom + :meth:`get_transform` method, as long as they are available in the + input data. In detectron2 we use the following convention: + + * image: (H,W) or (H,W,C) ndarray of type uint8 in range [0, 255], or + floating point in range [0, 1] or [0, 255]. + * boxes: (N,4) ndarray of float32. It represents the instance bounding boxes + of N instances. Each is in XYXY format in unit of absolute coordinates. + * sem_seg: (H,W) ndarray of type uint8. Each element is an integer label of pixel. + + We do not specify convention for other types and do not include builtin + :class:`Augmentation` that uses other types in detectron2. + """ + raise NotImplementedError + + def __call__(self, aug_input) -> Transform: + """ + Augment the given `aug_input` **in-place**, and return the transform that's used. + + This method will be called to apply the augmentation. In most augmentation, it + is enough to use the default implementation, which calls :meth:`get_transform` + using the inputs. But a subclass can overwrite it to have more complicated logic. + + Args: + aug_input (AugInput): an object that has attributes needed by this augmentation + (defined by ``self.get_transform``). Its ``transform`` method will be called + to in-place transform it. + + Returns: + Transform: the transform that is applied on the input. + """ + args = _get_aug_input_args(self, aug_input) + tfm = self.get_transform(*args) + assert isinstance(tfm, (Transform, TransformList)), ( + f"{type(self)}.get_transform must return an instance of Transform! Got {type(tfm)} instead." + ) + aug_input.transform(tfm) + return tfm + + def _rand_range(self, low=1.0, high=None, size=None): + """ + Uniform float random number between low and high. + """ + if high is None: + low, high = 0, low + if size is None: + size = [] + return np.random.uniform(low, high, size) + + def __repr__(self): + """ + Produce something like: + "MyAugmentation(field1={self.field1}, field2={self.field2})" + """ + try: + sig = inspect.signature(self.__init__) + classname = type(self).__name__ + argstr = [] + for name, param in sig.parameters.items(): + assert param.kind != param.VAR_POSITIONAL and param.kind != param.VAR_KEYWORD, ( + "The default __repr__ doesn't support *args or **kwargs" + ) + assert hasattr(self, name), ( + "Attribute {} not found! Default __repr__ only works if attributes match the constructor.".format( + name + ) + ) + attr = getattr(self, name) + default = param.default + if default is attr: + continue + attr_str = pprint.pformat(attr) + if "\n" in attr_str: + # don't show it if pformat decides to use >1 lines + attr_str = "..." + argstr.append("{}={}".format(name, attr_str)) + return "{}({})".format(classname, ", ".join(argstr)) + except AssertionError: + return super().__repr__() + + __str__ = __repr__ + + +class _TransformToAug(Augmentation): + def __init__(self, tfm: Transform): + self.tfm = tfm + + def get_transform(self, *args): + return self.tfm + + def __repr__(self): + return repr(self.tfm) + + __str__ = __repr__ + + +def _transform_to_aug(tfm_or_aug): + """ + Wrap Transform into Augmentation. + Private, used internally to implement augmentations. + """ + assert isinstance(tfm_or_aug, (Transform, Augmentation)), tfm_or_aug + if isinstance(tfm_or_aug, Augmentation): + return tfm_or_aug + else: + return _TransformToAug(tfm_or_aug) + + +class AugmentationList(Augmentation): + """ + Apply a sequence of augmentations. + + It has ``__call__`` method to apply the augmentations. + + Note that :meth:`get_transform` method is impossible (will throw error if called) + for :class:`AugmentationList`, because in order to apply a sequence of augmentations, + the kth augmentation must be applied first, to provide inputs needed by the (k+1)th + augmentation. + """ + + def __init__(self, augs): + """ + Args: + augs (list[Augmentation or Transform]): + """ + super().__init__() + self.augs = [_transform_to_aug(x) for x in augs] + + def __call__(self, aug_input) -> TransformList: + tfms = [] + for x in self.augs: + tfm = x(aug_input) + tfms.append(tfm) + return TransformList(tfms) + + def __repr__(self): + msgs = [str(x) for x in self.augs] + return "AugmentationList[{}]".format(", ".join(msgs)) + + __str__ = __repr__ + + +class AugInput: + """ + Input that can be used with :meth:`Augmentation.__call__`. + This is a standard implementation for the majority of use cases. + This class provides the standard attributes **"image", "boxes", "sem_seg"** + defined in :meth:`__init__` and they may be needed by different augmentations. + Most augmentation policies do not need attributes beyond these three. + + After applying augmentations to these attributes (using :meth:`AugInput.transform`), + the returned transforms can then be used to transform other data structures that users have. + + Examples: + :: + input = AugInput(image, boxes=boxes) + tfms = augmentation(input) + transformed_image = input.image + transformed_boxes = input.boxes + transformed_other_data = tfms.apply_other(other_data) + + An extended project that works with new data types may implement augmentation policies + that need other inputs. An algorithm may need to transform inputs in a way different + from the standard approach defined in this class. In those rare situations, users can + implement a class similar to this class, that satify the following condition: + + * The input must provide access to these data in the form of attribute access + (``getattr``). For example, if an :class:`Augmentation` to be applied needs "image" + and "sem_seg" arguments, its input must have the attribute "image" and "sem_seg". + * The input must have a ``transform(tfm: Transform) -> None`` method which + in-place transforms all its attributes. + """ + + # TODO maybe should support more builtin data types here + def __init__( + self, + image: np.ndarray, + *, + boxes: Optional[np.ndarray] = None, + sem_seg: Optional[np.ndarray] = None, + ): + """ + Args: + image (ndarray): (H,W) or (H,W,C) ndarray of type uint8 in range [0, 255], or + floating point in range [0, 1] or [0, 255]. The meaning of C is up + to users. + boxes (ndarray or None): Nx4 float32 boxes in XYXY_ABS mode + sem_seg (ndarray or None): HxW uint8 semantic segmentation mask. Each element + is an integer label of pixel. + """ + _check_img_dtype(image) + self.image = image + self.boxes = boxes + self.sem_seg = sem_seg + + def transform(self, tfm: Transform) -> None: + """ + In-place transform all attributes of this class. + + By "in-place", it means after calling this method, accessing an attribute such + as ``self.image`` will return transformed data. + """ + self.image = tfm.apply_image(self.image) # type: ignore + if self.boxes is not None: + self.boxes = tfm.apply_box(self.boxes) + if self.sem_seg is not None: + self.sem_seg = tfm.apply_segmentation(self.sem_seg) + + def apply_augmentations(self, augmentations: List[Union[Augmentation, Transform]]) -> TransformList: + """ + Equivalent of ``AugmentationList(augmentations)(self)`` + """ + return AugmentationList(augmentations)(self) + + +def apply_augmentations( + augmentations: List[Union[Transform, Augmentation]], inputs: AugInput +) -> Tuple[np.ndarray, TransformList]: + """ + Use ``T.AugmentationList(augmentations)(inputs)`` instead. + """ + if isinstance(inputs, np.ndarray): + # handle the common case of image-only Augmentation, also for backward compatibility + image_only = True + inputs = AugInput(inputs) + else: + image_only = False + tfms = inputs.apply_augmentations(augmentations) + return inputs.image if image_only else inputs, tfms # type: ignore + + +class RandomApply(Augmentation): + """ + Randomly apply an augmentation with a given probability. + """ + + def __init__(self, tfm_or_aug, prob=0.5): + """ + Args: + tfm_or_aug (Transform, Augmentation): the transform or augmentation + to be applied. It can either be a `Transform` or `Augmentation` + instance. + prob (float): probability between 0.0 and 1.0 that + the wrapper transformation is applied + """ + super().__init__() + self.aug = _transform_to_aug(tfm_or_aug) + assert 0.0 <= prob <= 1.0, f"Probablity must be between 0.0 and 1.0 (given: {prob})" + self.prob = prob + + def get_transform(self, *args): + do = self._rand_range() < self.prob + if do: + return self.aug.get_transform(*args) + else: + return NoOpTransform() + + def __call__(self, aug_input): + do = self._rand_range() < self.prob + if do: + return self.aug(aug_input) + else: + return NoOpTransform() + + def __repr__(self): + return f"RandomApply(prob={self.prob}, tfm={str(self.aug)})" + + def __str__(self): + return f"RandomApply(prob={self.prob}, tfm={str(self.aug)})" + + +class RandomFlip(Augmentation): + """ + Flip the image horizontally or vertically with the given probability. + """ + + def __init__(self, prob=0.5, *, horizontal=True, vertical=False): + """ + Args: + prob (float): probability of flip. + horizontal (boolean): whether to apply horizontal flipping + vertical (boolean): whether to apply vertical flipping + """ + super().__init__() + + if horizontal and vertical: + raise ValueError("Cannot do both horiz and vert. Please use two Flip instead.") + if not horizontal and not vertical: + raise ValueError("At least one of horiz or vert has to be True!") + self.prob = prob + self.horizontal = horizontal + self.vertical = vertical + + def get_transform(self, image): + h, w = image.shape[:2] + do = self._rand_range() < self.prob + if do: + if self.horizontal: + return HFlipTransform(w) + elif self.vertical: + return VFlipTransform(h) + else: + return NoOpTransform() + + def __repr__(self): + return f"RandomFlip(prob={self.prob}, horizontal={self.horizontal}, vertical={self.vertical})" + + +class Resize(Augmentation): + """Resize image to a fixed target size""" + + def __init__(self, shape, interp=Image.BILINEAR): + """ + Args: + shape: (h, w) tuple or a int + interp: PIL interpolation method + """ + if isinstance(shape, int): + shape = (shape, shape) + shape = tuple(shape) + self.shape = shape + self.interp = interp + + def get_transform(self, image): + return ResizeTransform(image.shape[0], image.shape[1], self.shape[0], self.shape[1], self.interp) + + def __repr__(self): + return f"Resize(shape={self.shape}, interp={self.interp})" + + +class ResizeShortestEdge(Augmentation): + """ + Resize the image while keeping the aspect ratio unchanged. + It attempts to scale the shorter edge to the given `short_edge_length`, + as long as the longer edge does not exceed `max_size`. + If `max_size` is reached, then downscale so that the longer edge does not exceed max_size. + """ + + def __init__( + self, + short_edge_length, + max_size=sys.maxsize, + sample_style="range", + interp=Image.BILINEAR, + ): + """ + Args: + short_edge_length (list[int]): If ``sample_style=="range"``, + a [min, max] interval from which to sample the shortest edge length. + If ``sample_style=="choice"``, a list of shortest edge lengths to sample from. + max_size (int): maximum allowed longest edge length. + sample_style (str): either "range" or "choice". + """ + super().__init__() + assert sample_style in ["range", "choice"], sample_style + + self.is_range = sample_style == "range" + if isinstance(short_edge_length, int): + short_edge_length = (short_edge_length, short_edge_length) + if self.is_range: + assert len(short_edge_length) == 2, ( + f"short_edge_length must be two values using 'range' sample style. Got {short_edge_length}!" + ) + self.max_size = max_size + self.short_edge_length = short_edge_length + self.interp = interp + + def get_transform(self, image): + h, w = image.shape[:2] + if self.is_range: + size = np.random.randint(self.short_edge_length[0], self.short_edge_length[1] + 1) + else: + size = np.random.choice(self.short_edge_length) + if size == 0: + return NoOpTransform() + + newh, neww = ResizeShortestEdge.get_output_shape(h, w, size, self.max_size) + return ResizeTransform(h, w, newh, neww, self.interp) + + def __repr__(self): + return f"ResizeShortestEdge(short_edge_length={self.short_edge_length}, max_size={self.max_size}, sample_style={'range' if self.is_range else 'choice'}, interp={self.interp})" + + def __str__(self): + return f"ResizeShortestEdge(short_edge_length={self.short_edge_length}, max_size={self.max_size}, sample_style={'range' if self.is_range else 'choice'}, interp={self.interp})" + + @staticmethod + def get_output_shape(oldh: int, oldw: int, short_edge_length: int, max_size: int) -> Tuple[int, int]: + """ + Compute the output size given input size and target short edge length. + """ + h, w = oldh, oldw + size = short_edge_length * 1.0 + scale = size / min(h, w) + if h < w: + newh, neww = size, scale * w + else: + newh, neww = scale * h, size + if max(newh, neww) > max_size: + scale = max_size * 1.0 / max(newh, neww) + newh = newh * scale + neww = neww * scale + neww = int(neww + 0.5) + newh = int(newh + 0.5) + return (newh, neww) + + +class ResizeScale(Augmentation): + """ + Takes target size as input and randomly scales the given target size between `min_scale` + and `max_scale`. It then scales the input image such that it fits inside the scaled target + box, keeping the aspect ratio constant. + This implements the resize part of the Google's 'resize_and_crop' data augmentation: + https://github.com/tensorflow/tpu/blob/master/models/official/detection/utils/input_utils.py#L127 + """ + + def __init__( + self, + min_scale: float, + max_scale: float, + target_height: int, + target_width: int, + interp: int = Image.BILINEAR, + ): + """ + Args: + min_scale: minimum image scale range. + max_scale: maximum image scale range. + target_height: target image height. + target_width: target image width. + interp: image interpolation method. + """ + super().__init__() + self.min_scale = min_scale + self.max_scale = max_scale + self.target_height = target_height + self.target_width = target_width + self.interp = interp + + def _get_resize(self, image: np.ndarray, scale: float) -> Transform: + input_size = image.shape[:2] + + # Compute new target size given a scale. + target_size = (self.target_height, self.target_width) + target_scale_size = np.multiply(target_size, scale) + + # Compute actual rescaling applied to input image and output size. + output_scale = np.minimum(target_scale_size[0] / input_size[0], target_scale_size[1] / input_size[1]) + output_size = np.round(np.multiply(input_size, output_scale)).astype(int) + + return ResizeTransform( + input_size[0], + input_size[1], + int(output_size[0]), + int(output_size[1]), + self.interp, + ) + + def get_transform(self, image: np.ndarray) -> Transform: + if self.min_scale == 1.0 and self.max_scale == 1.0: + return ResizeTransform(image.shape[0], image.shape[1], self.target_height, self.target_width, self.interp) + random_scale = np.random.uniform(self.min_scale, self.max_scale) + return self._get_resize(image, random_scale) + + def __repr__(self): + return f"ResizeScale(min_scale={self.min_scale}, max_scale={self.max_scale}, target_height={self.target_height}, target_width={self.target_width}, interp={self.interp})" + + +class RandomRotation(Augmentation): + """ + This method returns a copy of this image, rotated the given + number of degrees counter clockwise around the given center. + """ + + def __init__(self, angle, expand=True, center=None, sample_style="range", interp=None): + """ + Args: + angle (list[float]): If ``sample_style=="range"``, + a [min, max] interval from which to sample the angle (in degrees). + If ``sample_style=="choice"``, a list of angles to sample from + expand (bool): choose if the image should be resized to fit the whole + rotated image (default), or simply cropped + center (list[[float, float]]): If ``sample_style=="range"``, + a [[minx, miny], [maxx, maxy]] relative interval from which to sample the center, + [0, 0] being the top left of the image and [1, 1] the bottom right. + If ``sample_style=="choice"``, a list of centers to sample from + Default: None, which means that the center of rotation is the center of the image + center has no effect if expand=True because it only affects shifting + """ + super().__init__() + assert sample_style in ["range", "choice"], sample_style + self.is_range = sample_style == "range" + if isinstance(angle, (float, int)): + angle = (angle, angle) + if center is not None and isinstance(center[0], (float, int)): + center = (center, center) + self.angle = angle + self.expand = expand + self.center = center + self.interp = interp + + def get_transform(self, image): + h, w = image.shape[:2] + center = None + if self.is_range: + angle = np.random.uniform(self.angle[0], self.angle[1]) + if self.center is not None: + center = ( + np.random.uniform(self.center[0][0], self.center[1][0]), + np.random.uniform(self.center[0][1], self.center[1][1]), + ) + else: + angle = np.random.choice(self.angle) + if self.center is not None: + center = np.random.choice(self.center) + + if center is not None: + center = (w * center[0], h * center[1]) # Convert to absolute coordinates + + if angle % 360 == 0: + return NoOpTransform() + + return RotationTransform(h, w, angle, expand=self.expand, center=center, interp=self.interp) + + def __repr__(self): + return f"RandomRotation(angle={self.angle}, expand={self.expand}, center={self.center}, sample_style={'range' if self.is_range else 'choice'}, interp={self.interp})" + + +class FixedSizeCrop(Augmentation): + """ + If `crop_size` is smaller than the input image size, then it uses a random crop of + the crop size. If `crop_size` is larger than the input image size, then it pads + the right and the bottom of the image to the crop size if `pad` is True, otherwise + it returns the smaller image. + """ + + def __init__( + self, + crop_size: Tuple[int, int], + pad: bool = True, + pad_value: float = 128.0, + seg_pad_value: int = 255, + ): + """ + Args: + crop_size: target image (height, width). + pad: if True, will pad images smaller than `crop_size` up to `crop_size` + pad_value: the padding value to the image. + seg_pad_value: the padding value to the segmentation mask. + """ + super().__init__() + self.crop_size = crop_size + self.pad = pad + self.pad_value = pad_value + self.seg_pad_value = seg_pad_value + + def _get_crop(self, image: np.ndarray) -> Transform: + # Compute the image scale and scaled size. + input_size = image.shape[:2] + output_size = self.crop_size + + # Add random crop if the image is scaled up. + max_offset = np.subtract(input_size, output_size) + max_offset = np.maximum(max_offset, 0) + offset = np.multiply(max_offset, np.random.uniform(0.0, 1.0)) + offset = np.round(offset).astype(int) + return CropTransform( + offset[1], + offset[0], + output_size[1], + output_size[0], + input_size[1], + input_size[0], + ) + + def _get_pad(self, image: np.ndarray) -> Transform: + # Compute the image scale and scaled size. + input_size = image.shape[:2] + output_size = self.crop_size + + # Add padding if the image is scaled down. + pad_size = np.subtract(output_size, input_size) + pad_size = np.maximum(pad_size, 0) + original_size = np.minimum(input_size, output_size) + return PadTransform( + 0, + 0, + pad_size[1], + pad_size[0], + original_size[1], + original_size[0], + self.pad_value, + self.seg_pad_value, + ) + + def get_transform(self, image: np.ndarray) -> TransformList: + transforms = [self._get_crop(image)] + if self.pad: + transforms.append(self._get_pad(image)) + return TransformList(transforms) + + def __repr__(self): + return f"FixedSizeCrop(crop_size={self.crop_size}, pad={self.pad}, pad_value={self.pad_value}, seg_pad_value={self.seg_pad_value})" + + +class RandomCrop(Augmentation): + """ + Randomly crop a rectangle region out of an image. + """ + + def __init__(self, crop_type: str, crop_size): + """ + Args: + crop_type (str): one of "relative_range", "relative", "absolute", "absolute_range". + crop_size (tuple[float, float]): two floats, explained below. + + - "relative": crop a (H * crop_size[0], W * crop_size[1]) region from an input image of + size (H, W). crop size should be in (0, 1] + - "relative_range": uniformly sample two values from [crop_size[0], 1] + and [crop_size[1]], 1], and use them as in "relative" crop type. + - "absolute" crop a (crop_size[0], crop_size[1]) region from input image. + crop_size must be smaller than the input image size. + - "absolute_range", for an input of size (H, W), uniformly sample H_crop in + [crop_size[0], min(H, crop_size[1])] and W_crop in [crop_size[0], min(W, crop_size[1])]. + Then crop a region (H_crop, W_crop). + """ + # TODO style of relative_range and absolute_range are not consistent: + # one takes (h, w) but another takes (min, max) + super().__init__() + assert crop_type in ["relative_range", "relative", "absolute", "absolute_range"] + self.crop_type = crop_type + self.crop_size = crop_size + + def get_transform(self, image): + h, w = image.shape[:2] + croph, cropw = self.get_crop_size((h, w)) + assert h >= croph and w >= cropw, "Shape computation in {} has bugs.".format(self) + h0 = np.random.randint(h - croph + 1) + w0 = np.random.randint(w - cropw + 1) + return CropTransform(w0, h0, cropw, croph) # type: ignore + + def get_crop_size(self, image_size): + """ + Args: + image_size (tuple): height, width + + Returns: + crop_size (tuple): height, width in absolute pixels + """ + h, w = image_size + if self.crop_type == "relative": + ch, cw = self.crop_size + return int(h * ch + 0.5), int(w * cw + 0.5) + elif self.crop_type == "relative_range": + crop_size = np.asarray(self.crop_size, dtype=np.float32) + ch, cw = crop_size + np.random.rand(2) * (1 - crop_size) + return int(h * ch + 0.5), int(w * cw + 0.5) + elif self.crop_type == "absolute": + return (min(self.crop_size[0], h), min(self.crop_size[1], w)) + elif self.crop_type == "absolute_range": + assert self.crop_size[0] <= self.crop_size[1] + ch = np.random.randint(min(h, self.crop_size[0]), min(h, self.crop_size[1]) + 1) # type: ignore + cw = np.random.randint(min(w, self.crop_size[0]), min(w, self.crop_size[1]) + 1) # type: ignore + return ch, cw + else: + raise NotImplementedError("Unknown crop type {}".format(self.crop_type)) + + def __repr__(self): + return f"RandomCrop(crop_type={self.crop_type}, crop_size={self.crop_size})" + + +class RandomCrop_CategoryAreaConstraint(Augmentation): + """ + Similar to :class:`RandomCrop`, but find a cropping window such that no single category + occupies a ratio of more than `single_category_max_area` in semantic segmentation ground + truth, which can cause unstability in training. The function attempts to find such a valid + cropping window for at most 10 times. + """ + + def __init__( + self, + crop_type: str, + crop_size, + single_category_max_area: float = 1.0, + ignored_category: Optional[int] = None, + ): + """ + Args: + crop_type, crop_size: same as in :class:`RandomCrop` + single_category_max_area: the maximum allowed area ratio of a + category. Set to 1.0 to disable + ignored_category: allow this category in the semantic segmentation + ground truth to exceed the area ratio. Usually set to the category + that's ignored in training. + """ + self.crop_aug = RandomCrop(crop_type, crop_size) + self.single_category_max_area = single_category_max_area + self.ignored_category = ignored_category + + def get_transform(self, image, sem_seg): + if self.single_category_max_area >= 1.0: + return self.crop_aug.get_transform(image) + else: + h, w = sem_seg.shape + for _ in range(10): + crop_size = self.crop_aug.get_crop_size((h, w)) + y0 = np.random.randint(h - crop_size[0] + 1) + x0 = np.random.randint(w - crop_size[1] + 1) + sem_seg_temp = sem_seg[y0 : y0 + crop_size[0], x0 : x0 + crop_size[1]] + labels, cnt = np.unique(sem_seg_temp, return_counts=True) + if self.ignored_category is not None: + cnt = cnt[labels != self.ignored_category] + if len(cnt) > 1 and np.max(cnt) < np.sum(cnt) * self.single_category_max_area: + break + crop_tfm = CropTransform(x0, y0, crop_size[1], crop_size[0]) # type: ignore + return crop_tfm + + def __repr__(self): + return f"RandomCrop_CategoryAreaConstraint(crop_type={self.crop_aug.crop_type}, crop_size={self.crop_aug.crop_size}, single_category_max_area={self.single_category_max_area}, ignored_category={self.ignored_category})" + + +class RandomExtent(Augmentation): + """ + Outputs an image by cropping a random "subrect" of the source image. + + The subrect can be parameterized to include pixels outside the source image, + in which case they will be set to zeros (i.e. black). The size of the output + image will vary with the size of the random subrect. + """ + + def __init__(self, scale_range, shift_range): + """ + Args: + output_size (h, w): Dimensions of output image + scale_range (l, h): Range of input-to-output size scaling factor + shift_range (x, y): Range of shifts of the cropped subrect. The rect + is shifted by [w / 2 * Uniform(-x, x), h / 2 * Uniform(-y, y)], + where (w, h) is the (width, height) of the input image. Set each + component to zero to crop at the image's center. + """ + super().__init__() + self.scale_range = scale_range + self.shift_range = shift_range + + def get_transform(self, image): + img_h, img_w = image.shape[:2] + + # Initialize src_rect to fit the input image. + src_rect = np.array([-0.5 * img_w, -0.5 * img_h, 0.5 * img_w, 0.5 * img_h]) + + # Apply a random scaling to the src_rect. + src_rect *= np.random.uniform(self.scale_range[0], self.scale_range[1]) + + # Apply a random shift to the coordinates origin. + src_rect[0::2] += self.shift_range[0] * img_w * (np.random.rand() - 0.5) + src_rect[1::2] += self.shift_range[1] * img_h * (np.random.rand() - 0.5) + + # Map src_rect coordinates into image coordinates (center at corner). + src_rect[0::2] += 0.5 * img_w + src_rect[1::2] += 0.5 * img_h + + return ExtentTransform( + src_rect=(src_rect[0], src_rect[1], src_rect[2], src_rect[3]), + output_size=( + int(src_rect[3] - src_rect[1]), + int(src_rect[2] - src_rect[0]), + ), + ) + + def __repr__(self): + return f"RandomExtent(scale_range={self.scale_range}, shift_range={self.shift_range})" + + +class RandomContrast(Augmentation): + """ + Randomly transforms image contrast. + + Contrast intensity is uniformly sampled in (intensity_min, intensity_max). + - intensity < 1 will reduce contrast + - intensity = 1 will preserve the input image + - intensity > 1 will increase contrast + + See: https://pillow.readthedocs.io/en/3.0.x/reference/ImageEnhance.html + """ + + def __init__(self, intensity_min, intensity_max): + """ + Args: + intensity_min (float): Minimum augmentation + intensity_max (float): Maximum augmentation + """ + super().__init__() + self.intensity_min = intensity_min + self.intensity_max = intensity_max + + def get_transform(self, image): + w = np.random.uniform(self.intensity_min, self.intensity_max) + return BlendTransform(src_image=image.mean(), src_weight=1 - w, dst_weight=w) + + def __repr__(self): + return f"RandomContrast(intensity_min={self.intensity_min}, intensity_max={self.intensity_max})" + + +class RandomBrightness(Augmentation): + """ + Randomly transforms image brightness. + + Brightness intensity is uniformly sampled in (intensity_min, intensity_max). + - intensity < 1 will reduce brightness + - intensity = 1 will preserve the input image + - intensity > 1 will increase brightness + + See: https://pillow.readthedocs.io/en/3.0.x/reference/ImageEnhance.html + """ + + def __init__(self, intensity_min, intensity_max): + """ + Args: + intensity_min (float): Minimum augmentation + intensity_max (float): Maximum augmentation + """ + super().__init__() + self.intensity_min = intensity_min + self.intensity_max = intensity_max + + def get_transform(self, image): + w = np.random.uniform(self.intensity_min, self.intensity_max) + return BlendTransform(src_image=0, src_weight=1 - w, dst_weight=w) # type: ignore + + def __repr__(self): + return f"RandomBrightness(intensity_min={self.intensity_min}, intensity_max={self.intensity_max})" + + +class RandomSaturation(Augmentation): + """ + Randomly transforms saturation of an RGB image. + Input images are assumed to have 'RGB' channel order. + + Saturation intensity is uniformly sampled in (intensity_min, intensity_max). + - intensity < 1 will reduce saturation (make the image more grayscale) + - intensity = 1 will preserve the input image + - intensity > 1 will increase saturation + + See: https://pillow.readthedocs.io/en/3.0.x/reference/ImageEnhance.html + """ + + def __init__(self, intensity_min, intensity_max): + """ + Args: + intensity_min (float): Minimum augmentation (1 preserves input). + intensity_max (float): Maximum augmentation (1 preserves input). + """ + super().__init__() + self.intensity_min = intensity_min + self.intensity_max = intensity_max + + def get_transform(self, image): + assert image.shape[-1] == 3, "RandomSaturation only works on RGB images" + w = np.random.uniform(self.intensity_min, self.intensity_max) + grayscale = image.dot([0.299, 0.587, 0.114])[:, :, np.newaxis] + return BlendTransform(src_image=grayscale, src_weight=1 - w, dst_weight=w) + + def __repr__(self): + return f"RandomSaturation(intensity_min={self.intensity_min}, intensity_max={self.intensity_max})" + + +class RandomLighting(Augmentation): + """ + The "lighting" augmentation described in AlexNet, using fixed PCA over ImageNet. + Input images are assumed to have 'RGB' channel order. + + The degree of color jittering is randomly sampled via a normal distribution, + with standard deviation given by the scale parameter. + """ + + def __init__(self, scale): + """ + Args: + scale (float): Standard deviation of principal component weighting. + """ + super().__init__() + self.scale = scale + + self.eigen_vecs = np.array( + [ + [-0.5675, 0.7192, 0.4009], + [-0.5808, -0.0045, -0.8140], + [-0.5836, -0.6948, 0.4203], + ] + ) + self.eigen_vals = np.array([0.2175, 0.0188, 0.0045]) + + def get_transform(self, image): + assert image.shape[-1] == 3, "RandomLighting only works on RGB images" + weights = np.random.normal(scale=self.scale, size=3) + return BlendTransform( + src_image=self.eigen_vecs.dot(weights * self.eigen_vals), + src_weight=1.0, + dst_weight=1.0, + ) + + def __repr__(self): + return f"RandomLighting(scale={self.scale})" + + +class RandomResize(Augmentation): + """Randomly resize image to a target size in shape_list""" + + def __init__(self, shape_list, interp=Image.BILINEAR): + """ + Args: + shape_list: a list of shapes in (h, w) + interp: PIL interpolation method + """ + self.shape_list = shape_list + self.interp = interp + + def get_transform(self, image): + shape_idx = np.random.randint(low=0, high=len(self.shape_list)) + h, w = self.shape_list[shape_idx] + return ResizeTransform(image.shape[0], image.shape[1], h, w, self.interp) # type: ignore + + def __repr__(self): + return f"RandomResize(shape_list={self.shape_list}, interp={self.interp})" + + +class RandomAspectRatio(Augmentation): + """Randomly resize image to a target aspect ratio + + The aspect ratio + Example: given an image of shape (h: 512, w: 512) and aspect_ratio=2.0, + the image will be resized to a new shape in (h: 512, w: 256) or (h: 512, w: 1024). + """ + + def __init__(self, aspect_ratio=1.0): + assert aspect_ratio > 0.0, "Minimum aspect ratio must be greater than 0" + self.aspect_ratio = aspect_ratio + + def get_transform(self, image): + aspect_ratio = 2 ** np.random.uniform(-self.aspect_ratio, self.aspect_ratio, size=1)[0] + h, w = image.shape[:2] + # Determine whether to modify width or height + if aspect_ratio > 1.0: + # Increase width or decrease height + if random.random() < 0.5: + new_w = int(w * aspect_ratio) + new_h = h + else: + new_w = w + new_h = int(h / aspect_ratio) + else: + # Increase height or decrease width + if random.random() < 0.5: + new_w = w + new_h = int(h * (1.0 / aspect_ratio)) + else: + new_w = int(w * aspect_ratio) + new_h = h + + return ResizeTransform(image.shape[0], image.shape[1], new_h, new_w, Image.BILINEAR) + + +class MinIoURandomCrop(Augmentation): + """Random crop the image & bboxes, the cropped patches have minimum IoU + requirement with original image & bboxes, the IoU threshold is randomly + selected from min_ious. + + Args: + min_ious (tuple): minimum IoU threshold for all intersections with + bounding boxes + min_crop_size (float): minimum crop's size (i.e. h,w := a*h, a*w, + where a >= min_crop_size) + mode_trials: number of trials for sampling min_ious threshold + crop_trials: number of trials for sampling crop_size after cropping + """ + + def __init__( + self, + min_ious=(0.1, 0.3, 0.5, 0.7, 0.9), + min_crop_size=0.3, + mode_trials=1000, + crop_trials=50, + ): + self.min_ious = min_ious + self.sample_mode = (1, *min_ious, 0) + self.min_crop_size = min_crop_size + self.mode_trials = mode_trials + self.crop_trials = crop_trials + + def get_transform(self, image, boxes): + """Call function to crop images and bounding boxes with minimum IoU + constraint. + + Args: + boxes: ground truth boxes in (x1, y1, x2, y2) format + """ + if boxes is None: + return NoOpTransform() + h, w, c = image.shape + for _ in range(self.mode_trials): + mode = random.choice(self.sample_mode) + self.mode = mode + if mode == 1: + return NoOpTransform() + + min_iou = mode + for _ in range(self.crop_trials): + new_w = random.uniform(self.min_crop_size * w, w) + new_h = random.uniform(self.min_crop_size * h, h) + + # h / w in [0.5, 2] + if new_h / new_w < 0.5 or new_h / new_w > 2: + continue + + left = random.uniform(w - new_w) + top = random.uniform(h - new_h) + + patch = np.array((int(left), int(top), int(left + new_w), int(top + new_h))) + # Line or point crop is not allowed + if patch[2] == patch[0] or patch[3] == patch[1]: + continue + overlaps = pairwise_iou(Boxes(patch.reshape(-1, 4)), Boxes(boxes.reshape(-1, 4))).reshape(-1) # type: ignore + if len(overlaps) > 0 and overlaps.min() < min_iou: + continue + + # center of boxes should inside the crop img + # only adjust boxes and instance masks when the gt is not empty + if len(overlaps) > 0: + # adjust boxes + def is_center_of_bboxes_in_patch(boxes, patch): + center = (boxes[:, :2] + boxes[:, 2:]) / 2 + mask = ( + (center[:, 0] > patch[0]) + * (center[:, 1] > patch[1]) + * (center[:, 0] < patch[2]) + * (center[:, 1] < patch[3]) + ) + return mask + + mask = is_center_of_bboxes_in_patch(boxes, patch) + if not mask.any(): + continue + return CropTransform(int(left), int(top), int(new_w), int(new_h)) + + def __repr__(self): + return f"MinIoURandomCrop(min_ious={self.min_ious}, min_crop_size={self.min_crop_size}, mode_trials={self.mode_trials}, crop_trials={self.crop_trials})" + + +class RandomZoomOut(Augmentation): + def __init__(self, side_range=(1.0, 4.0), pad_value=0.0, seg_pad_value=0): + """ + Args: + prob (float): probability of flip. + """ + super().__init__() + self.fill = pad_value + self.fill_seg = seg_pad_value + self.side_range = side_range + if side_range[0] < 1.0 or side_range[0] > side_range[1]: + raise ValueError(f"Invalid canvas side range provided {side_range}.") + + def get_transform(self, image): + return self._get_transform(image) + + def _get_transform(self, image): + orig_h, orig_w = image.shape[:2] + + r = self.side_range[0] + self._rand_range() * (self.side_range[1] - self.side_range[0]) + canvas_width = int(orig_w * r) + canvas_height = int(orig_h * r) + + r = self._rand_range(size=2) + left = int((canvas_width - orig_w) * r[0]) + top = int((canvas_height - orig_h) * r[1]) + right = canvas_width - (left + orig_w) + bottom = canvas_height - (top + orig_h) + return PadTransform( + x0=left, + y0=top, + x1=right, + y1=bottom, + pad_value=self.fill, + seg_pad_value=self.fill_seg, + ) + + def __repr__(self): + return f"RandomZoomOut(side_range={self.side_range}, pad_value={self.fill}, seg_pad_value={self.fill_seg})" diff --git a/focoos/data/transforms/resize_short_length.py b/focoos/data/transforms/resize_short_length.py new file mode 100644 index 00000000..646c90bd --- /dev/null +++ b/focoos/data/transforms/resize_short_length.py @@ -0,0 +1,53 @@ +import os +from pathlib import Path +from typing import Tuple + +from PIL import Image + + +def get_output_shape(old_height: int, old_width: int, short_edge_length: int, max_size: int) -> Tuple[int, int]: + """ + Compute the output size given input size and target short edge length. + """ + h, w = old_height, old_width + size = short_edge_length * 1.0 + scale = size / min(h, w) + if h < w: + newh, neww = size, scale * w + else: + newh, neww = scale * h, size + if max(newh, neww) > max_size: + scale = max_size * 1.0 / max(newh, neww) + newh = newh * scale + neww = neww * scale + neww = int(neww + 0.5) + newh = int(newh + 0.5) + + return neww, newh + + +def resize_shortest_length( + im_path: str, + out_path: str, + shortest_length: int = 1024, + max_size: int = 2048, + is_mask: bool = False, +): + im_name = Path(im_path).name + out_path = os.path.join(out_path, im_name) + im = Image.open(im_path) + new_width, new_height = get_output_shape( + old_width=im.size[0], + old_height=im.size[1], + short_edge_length=shortest_length, + max_size=max_size, + ) + if is_mask: + # mask = np.array(im,dtype=np.uint8) + # mask = np.zeros((new_height, new_width), dtype=np.uint8) + # print(mask.shape,mask.max()) + # im = Image.fromarray(mask.astype(np.uint8)) + im = im.resize((new_width, new_height), resample=Image.Resampling.NEAREST) + else: + im = im.resize((new_width, new_height)) + im.save(out_path) diff --git a/focoos/data/transforms/transform.py b/focoos/data/transforms/transform.py new file mode 100644 index 00000000..45b015fb --- /dev/null +++ b/focoos/data/transforms/transform.py @@ -0,0 +1,462 @@ +# Copyright (c) Facebook, Inc. and its affiliates. +import random + +import cv2 +import numpy as np +import torch +import torch.nn.functional as F +from fvcore.transforms.transform import ( + CropTransform, + HFlipTransform, + NoOpTransform, + Transform, + TransformList, +) +from PIL import Image + +try: + import cv2 # noqa +except ImportError: + # OpenCV is an optional dependency at the moment + pass + +__all__ = [ + "ExtentTransform", + "ResizeTransform", + "RotationTransform", + "ColorTransform", + "PILColorTransform", + "ColorAugSSDTransform", +] + + +class ExtentTransform(Transform): + """ + Extracts a subregion from the source image and scales it to the output size. + + The fill color is used to map pixels from the source rect that fall outside + the source image. + + See: https://pillow.readthedocs.io/en/latest/PIL.html#PIL.ImageTransform.ExtentTransform + """ + + def __init__(self, src_rect, output_size, interp=Image.BILINEAR, fill=0): + """ + Args: + src_rect (x0, y0, x1, y1): src coordinates + output_size (h, w): dst image size + interp: PIL interpolation methods + fill: Fill color used when src_rect extends outside image + """ + super().__init__() + self.src_rect = src_rect + self.output_size = output_size + self.interp = interp + self.fill = fill + + def apply_image(self, img, interp=None): + h, w = self.output_size + if len(img.shape) > 2 and img.shape[2] == 1: + pil_image = Image.fromarray(img[:, :, 0], mode="L") + else: + pil_image = Image.fromarray(img) + pil_image = pil_image.transform( + size=(w, h), + method=Image.EXTENT, + data=self.src_rect, + resample=interp if interp else self.interp, + fill=self.fill, + ) + ret = np.asarray(pil_image) + if len(img.shape) > 2 and img.shape[2] == 1: + ret = np.expand_dims(ret, -1) + return ret + + def apply_coords(self, coords): + # Transform image center from source coordinates into output coordinates + # and then map the new origin to the corner of the output image. + h, w = self.output_size + x0, y0, x1, y1 = self.src_rect + new_coords = coords.astype(np.float32) + new_coords[:, 0] -= 0.5 * (x0 + x1) + new_coords[:, 1] -= 0.5 * (y0 + y1) + new_coords[:, 0] *= w / (x1 - x0) + new_coords[:, 1] *= h / (y1 - y0) + new_coords[:, 0] += 0.5 * w + new_coords[:, 1] += 0.5 * h + return new_coords + + def apply_segmentation(self, segmentation): + segmentation = self.apply_image(segmentation, interp=Image.NEAREST) + return segmentation + + +class ResizeTransform(Transform): + """ + Resize the image to a target size. + """ + + def __init__(self, h, w, new_h, new_w, interp=None): + """ + Args: + h, w (int): original image size + new_h, new_w (int): new image size + interp: PIL interpolation methods, defaults to bilinear. + """ + # TODO decide on PIL vs opencv + super().__init__() + if interp is None: + interp = Image.BILINEAR + self.h = h + self.w = w + self.new_h = new_h + self.new_w = new_w + self.interp = interp + + def apply_image(self, img, interp=None): + assert img.shape[:2] == (self.h, self.w) + assert len(img.shape) <= 4 + interp_method = interp if interp is not None else self.interp + + if img.dtype == np.uint8: + if len(img.shape) > 2 and img.shape[2] == 1: + pil_image = Image.fromarray(img[:, :, 0], mode="L") + else: + pil_image = Image.fromarray(img) + pil_image = pil_image.resize((self.new_w, self.new_h), interp_method) + ret = np.asarray(pil_image) + if len(img.shape) > 2 and img.shape[2] == 1: + ret = np.expand_dims(ret, -1) + else: + # PIL only supports uint8 + if any(x < 0 for x in img.strides): + img = np.ascontiguousarray(img) + img = torch.from_numpy(img) + shape = list(img.shape) + shape_4d = shape[:2] + [1] * (4 - len(shape)) + shape[2:] + img = img.view(shape_4d).permute(2, 3, 0, 1) # hw(c) -> nchw + _PIL_RESIZE_TO_INTERPOLATE_MODE = { + Image.NEAREST: "nearest", + Image.BILINEAR: "bilinear", + Image.BICUBIC: "bicubic", + } + mode = _PIL_RESIZE_TO_INTERPOLATE_MODE[interp_method] + align_corners = None if mode == "nearest" else False + img = F.interpolate(img, (self.new_h, self.new_w), mode=mode, align_corners=align_corners) + shape[:2] = (self.new_h, self.new_w) + ret = img.permute(2, 3, 0, 1).view(shape).numpy() # nchw -> hw(c) + + return ret + + def apply_coords(self, coords): + coords[:, 0] = coords[:, 0] * (self.new_w * 1.0 / self.w) + coords[:, 1] = coords[:, 1] * (self.new_h * 1.0 / self.h) + return coords + + def apply_segmentation(self, segmentation): + segmentation = self.apply_image(segmentation, interp=Image.NEAREST) + return segmentation + + def inverse(self): + return ResizeTransform(self.new_h, self.new_w, self.h, self.w, self.interp) + + +class RotationTransform(Transform): + """ + This method returns a copy of this image, rotated the given + number of degrees counter clockwise around its center. + """ + + def __init__(self, h, w, angle, expand=True, center=None, interp=None): + """ + Args: + h, w (int): original image size + angle (float): degrees for rotation + expand (bool): choose if the image should be resized to fit the whole + rotated image (default), or simply cropped + center (tuple (width, height)): coordinates of the rotation center + if left to None, the center will be fit to the center of each image + center has no effect if expand=True because it only affects shifting + interp: cv2 interpolation method, default cv2.INTER_LINEAR + """ + super().__init__() + self.image_center = np.array((w / 2, h / 2)) + if center is None: + center = self.image_center + if interp is None: + self.interp = cv2.INTER_LINEAR + abs_cos, abs_sin = ( + abs(np.cos(np.deg2rad(angle))), + abs(np.sin(np.deg2rad(angle))), + ) + if expand: + # find the new width and height bounds + bound_w, bound_h = np.rint([h * abs_sin + w * abs_cos, h * abs_cos + w * abs_sin]).astype(int) + else: + bound_w, bound_h = w, h + + self.bound_w = bound_w + self.bound_h = bound_h + self.angle = angle + self.expand = expand + self.center = center + self.interp = interp + self.h = h + self.w = w + + self.rm_coords = self.create_rotation_matrix() + # Needed because of this problem https://github.com/opencv/opencv/issues/11784 + self.rm_image = self.create_rotation_matrix(offset=-0.5) + + def apply_image(self, img, interp=None): + """ + img should be a numpy array, formatted as Height * Width * Nchannels + """ + if len(img) == 0 or self.angle % 360 == 0: + return img + assert img.shape[:2] == (self.h, self.w) + interp = interp if interp is not None else self.interp + return cv2.warpAffine(img, self.rm_image, (self.bound_w, self.bound_h), flags=interp) + + def apply_coords(self, coords): + """ + coords should be a N * 2 array-like, containing N couples of (x, y) points + """ + coords = np.asarray(coords, dtype=float) + if len(coords) == 0 or self.angle % 360 == 0: + return coords + return cv2.transform(coords[:, np.newaxis, :], self.rm_coords)[:, 0, :] + + def apply_segmentation(self, segmentation): + segmentation = self.apply_image(segmentation, interp=cv2.INTER_NEAREST) + return segmentation + + def create_rotation_matrix(self, offset=0): + center = (self.center[0] + offset, self.center[1] + offset) + rm = cv2.getRotationMatrix2D(tuple(center), self.angle, 1) + if self.expand: + # Find the coordinates of the center of rotation in the new image + # The only point for which we know the future coordinates is the center of the image + rot_im_center = cv2.transform(self.image_center[None, None, :] + offset, rm)[0, 0, :] + new_center = np.array([self.bound_w / 2, self.bound_h / 2]) + offset - rot_im_center + # shift the rotation center to the new coordinates + rm[:, 2] += new_center + return rm + + def inverse(self): + """ + The inverse is to rotate it back with expand, and crop to get the original shape. + """ + if not self.expand: # Not possible to inverse if a part of the image is lost + raise NotImplementedError() + rotation = RotationTransform(self.bound_h, self.bound_w, -self.angle, True, None, self.interp) + crop = CropTransform( + (rotation.bound_w - self.w) // 2, + (rotation.bound_h - self.h) // 2, + self.w, + self.h, + ) + return TransformList([rotation, crop]) + + +class ColorTransform(Transform): + """ + Generic wrapper for any photometric transforms. + These transformations should only affect the color space and + not the coordinate space of the image (e.g. annotation + coordinates such as bounding boxes should not be changed) + """ + + def __init__(self, op): + """ + Args: + op (Callable): operation to be applied to the image, + which takes in an ndarray and returns an ndarray. + """ + if not callable(op): + raise ValueError("op parameter should be callable") + super().__init__() + self.op = op + + def apply_image(self, img): + return self.op(img) + + def apply_coords(self, coords): + return coords + + def inverse(self): + return NoOpTransform() + + def apply_segmentation(self, segmentation): + return segmentation + + +class PILColorTransform(ColorTransform): + """ + Generic wrapper for PIL Photometric image transforms, + which affect the color space and not the coordinate + space of the image + """ + + def __init__(self, op): + """ + Args: + op (Callable): operation to be applied to the image, + which takes in a PIL Image and returns a transformed + PIL Image. + For reference on possible operations see: + - https://pillow.readthedocs.io/en/stable/ + """ + if not callable(op): + raise ValueError("op parameter should be callable") + super().__init__(op) + self.op = op + + def apply_image(self, img): + img = Image.fromarray(img) + return np.asarray(super().apply_image(img)) + + +def HFlip_rotated_box(transform, rotated_boxes): + """ + Apply the horizontal flip transform on rotated boxes. + + Args: + rotated_boxes (ndarray): Nx5 floating point array of + (x_center, y_center, width, height, angle_degrees) format + in absolute coordinates. + """ + # Transform x_center + rotated_boxes[:, 0] = transform.width - rotated_boxes[:, 0] + # Transform angle + rotated_boxes[:, 4] = -rotated_boxes[:, 4] + return rotated_boxes + + +def Resize_rotated_box(transform, rotated_boxes): + """ + Apply the resizing transform on rotated boxes. For details of how these (approximation) + formulas are derived, please refer to :meth:`RotatedBoxes.scale`. + + Args: + rotated_boxes (ndarray): Nx5 floating point array of + (x_center, y_center, width, height, angle_degrees) format + in absolute coordinates. + """ + scale_factor_x = transform.new_w * 1.0 / transform.w + scale_factor_y = transform.new_h * 1.0 / transform.h + rotated_boxes[:, 0] *= scale_factor_x + rotated_boxes[:, 1] *= scale_factor_y + theta = rotated_boxes[:, 4] * np.pi / 180.0 + c = np.cos(theta) + s = np.sin(theta) + rotated_boxes[:, 2] *= np.sqrt(np.square(scale_factor_x * c) + np.square(scale_factor_y * s)) + rotated_boxes[:, 3] *= np.sqrt(np.square(scale_factor_x * s) + np.square(scale_factor_y * c)) + rotated_boxes[:, 4] = np.arctan2(scale_factor_x * s, scale_factor_y * c) * 180 / np.pi + + return rotated_boxes + + +HFlipTransform.register_type("rotated_box", HFlip_rotated_box) +ResizeTransform.register_type("rotated_box", Resize_rotated_box) + +# not necessary any more with latest fvcore +NoOpTransform.register_type("rotated_box", lambda t, x: x) + + +class ColorAugSSDTransform(Transform): + """ + A color related data augmentation used in Single Shot Multibox Detector (SSD). + + Wei Liu, Dragomir Anguelov, Dumitru Erhan, Christian Szegedy, + Scott Reed, Cheng-Yang Fu, Alexander C. Berg. + SSD: Single Shot MultiBox Detector. ECCV 2016. + + Implementation based on: + + https://github.com/weiliu89/caffe/blob + /4817bf8b4200b35ada8ed0dc378dceaf38c539e4 + /src/caffe/util/im_transforms.cpp + + https://github.com/chainer/chainercv/blob + /7159616642e0be7c5b3ef380b848e16b7e99355b/chainercv + /links/model/ssd/transforms.py + """ + + def __init__( + self, + img_format, + brightness_delta=32, + contrast_low=0.5, + contrast_high=1.5, + saturation_low=0.5, + saturation_high=1.5, + hue_delta=18, + ): + super().__init__() + assert img_format in ["BGR", "RGB"] + self.is_rgb = img_format == "RGB" + self.brightness_delta = brightness_delta + self.contrast_low = contrast_low + self.contrast_high = contrast_high + self.saturation_low = saturation_low + self.saturation_high = saturation_high + self.hue_delta = hue_delta + self.img_format = img_format + + def apply_coords(self, coords): + return coords + + def apply_segmentation(self, segmentation): + return segmentation + + def apply_image(self, img, interp=None): + if self.is_rgb: + img = img[:, :, [2, 1, 0]] + img = self.brightness(img) + if random.randrange(2): + img = self.contrast(img) + img = self.saturation(img) + img = self.hue(img) + else: + img = self.saturation(img) + img = self.hue(img) + img = self.contrast(img) + if self.is_rgb: + img = img[:, :, [2, 1, 0]] + return img + + def convert(self, img, alpha: float = 1, beta: float = 0): + img = img.astype(np.float32) * alpha + beta + img = np.clip(img, 0, 255) + return img.astype(np.uint8) + + def brightness(self, img): + if random.randrange(2): + return self.convert(img, beta=random.uniform(-self.brightness_delta, self.brightness_delta)) + return img + + def contrast(self, img): + if random.randrange(2): + return self.convert(img, alpha=random.uniform(self.contrast_low, self.contrast_high)) + return img + + def saturation(self, img): + if random.randrange(2): + img = cv2.cvtColor(img, cv2.COLOR_BGR2HSV) + img[:, :, 1] = self.convert( + img[:, :, 1], + alpha=random.uniform(self.saturation_low, self.saturation_high), + ) + return cv2.cvtColor(img, cv2.COLOR_HSV2BGR) + return img + + def hue(self, img): + if random.randrange(2): + img = cv2.cvtColor(img, cv2.COLOR_BGR2HSV) + img[:, :, 0] = (img[:, :, 0].astype(int) + random.randint(-self.hue_delta, self.hue_delta)) % 180 + return cv2.cvtColor(img, cv2.COLOR_HSV2BGR) + return img + + def __repr__(self): + return f"ColorAugSSDTransform(img_format={self.img_format}, brightness_delta={self.brightness_delta}, contrast_low={self.contrast_low}, contrast_high={self.contrast_high}, saturation_low={self.saturation_low}, saturation_high={self.saturation_high}, hue_delta={self.hue_delta})" diff --git a/focoos/data/utils.py b/focoos/data/utils.py new file mode 100644 index 00000000..b92ec0ef --- /dev/null +++ b/focoos/data/utils.py @@ -0,0 +1,376 @@ +import numpy as np +import pycocotools.mask as mask_util +import torch +from PIL import Image + +import focoos.data.transforms as T +from focoos.structures import ( + BitMasks, + Boxes, + BoxMode, + Instances, + Keypoints, + polygons_to_bitmask, +) +from focoos.utils.logger import get_logger + +# https://en.wikipedia.org/wiki/YUV#SDTV_with_BT.601 +_M_RGB2YUV = [ + [0.299, 0.587, 0.114], + [-0.14713, -0.28886, 0.436], + [0.615, -0.51499, -0.10001], +] + + +def convert_coco_poly_to_mask(segmentations, height, width): + masks = [] + for polygons in segmentations: + rles = mask_util.frPyObjects(polygons, height, width) + mask = mask_util.decode(rles) + if len(mask.shape) < 3: + mask = mask[..., None] + mask = torch.as_tensor(mask, dtype=torch.uint8) + mask = mask.any(dim=2) + masks.append(mask) + if masks: + masks = torch.stack(masks, dim=0) + else: + masks = torch.zeros((0, height, width), dtype=torch.uint8) + return masks + + +def filter_images_with_only_crowd_annotations(dataset_dicts): + """ + Filter out images with none annotations or only crowd annotations + (i.e., images without non-crowd annotations). + A common training-time preprocessing on COCO dataset. + + Args: + dataset_dicts (list[dict]): annotations in Detectron2 Dataset format. + + Returns: + list[dict]: the same format, but filtered. + """ + num_before = len(dataset_dicts) + + def valid(anns): + for ann in anns: + if ann.get("iscrowd", 0) == 0: + return True + return False + + dataset_dicts = [x for x in dataset_dicts if valid(x.annotations)] + num_after = len(dataset_dicts) + logger = get_logger(__name__) + logger.info( + "Removed {} images with no usable annotations. {} images left.".format(num_before - num_after, num_after) + ) + return dataset_dicts + + +def transform_instance_annotations(annotation, transforms, image_size, *, keypoint_hflip_indices=None): + """ + Apply transforms to box, segmentation and keypoints annotations of a single instance. + + It will use `transforms.apply_box` for the box, and + `transforms.apply_coords` for segmentation polygons & keypoints. + If you need anything more specially designed for each data structure, + you'll need to implement your own version of this function or the transforms. + + Args: + annotation (dict): dict of instance annotations for a single instance. + It will be modified in-place. + transforms (TransformList or list[Transform]): + image_size (tuple): the height, width of the transformed image + keypoint_hflip_indices (ndarray[int]): see `create_keypoint_hflip_indices`. + + Returns: + dict: + the same input dict with fields "bbox", "segmentation", "keypoints" + transformed according to `transforms`. + The "bbox_mode" field will be set to XYXY_ABS. + """ + # bbox is 1d (per-instance bounding box) + bbox = BoxMode.convert(annotation["bbox"], annotation["bbox_mode"], BoxMode.XYXY_ABS) + # clip transformed bbox to image size + bbox = transforms.apply_box(np.array([bbox]))[0].clip(min=0) + annotation["bbox"] = np.minimum(bbox, list(image_size + image_size)[::-1]) + annotation["bbox_mode"] = BoxMode.XYXY_ABS + + if "segmentation" in annotation: + # each instance contains 1 or more polygons + segm = annotation["segmentation"] + if isinstance(segm, list): + # polygons + polygons = [np.asarray(p).reshape(-1, 2) for p in segm] + annotation["segmentation"] = [p.reshape(-1) for p in transforms.apply_polygons(polygons)] + elif isinstance(segm, dict): + # RLE + mask = mask_util.decode(segm) # type: ignore + mask = transforms.apply_segmentation(mask) + assert tuple(mask.shape[:2]) == image_size + annotation["segmentation"] = mask + else: + raise ValueError( + "Cannot transform segmentation of type '{}'!" + "Supported types are: polygons as list[list[float] or ndarray]," + " COCO-style RLE as a dict.".format(type(segm)) + ) + + if "keypoints" in annotation: + keypoints = transform_keypoint_annotations( + annotation["keypoints"], transforms, image_size, keypoint_hflip_indices + ) + annotation["keypoints"] = keypoints + + return annotation + + +def transform_keypoint_annotations(keypoints, transforms, image_size, keypoint_hflip_indices=None): + """ + Transform keypoint annotations of an image. + If a keypoint is transformed out of image boundary, it will be marked "unlabeled" (visibility=0) + + Args: + keypoints (list[float]): Nx3 float in Detectron2's Dataset format. + Each point is represented by (x, y, visibility). + transforms (TransformList): + image_size (tuple): the height, width of the transformed image + keypoint_hflip_indices (ndarray[int]): see `create_keypoint_hflip_indices`. + When `transforms` includes horizontal flip, will use the index + mapping to flip keypoints. + """ + # (N*3,) -> (N, 3) + keypoints = np.asarray(keypoints, dtype="float64").reshape(-1, 3) + keypoints_xy = transforms.apply_coords(keypoints[:, :2]) + + # Set all out-of-boundary points to "unlabeled" + inside = (keypoints_xy >= np.array([0, 0])) & (keypoints_xy <= np.array(image_size[::-1])) + inside = inside.all(axis=1) + keypoints[:, :2] = keypoints_xy + keypoints[:, 2][~inside] = 0 + + # This assumes that HorizFlipTransform is the only one that does flip + + do_hflip = sum(isinstance(t, T.HFlipTransform) for t in transforms.transforms) % 2 == 1 + + # Alternative way: check if probe points was horizontally flipped. + # probe = np.asarray([[0.0, 0.0], [image_width, 0.0]]) + # probe_aug = transforms.apply_coords(probe.copy()) + # do_hflip = np.sign(probe[1][0] - probe[0][0]) != np.sign(probe_aug[1][0] - probe_aug[0][0]) # noqa + + # If flipped, swap each keypoint with its opposite-handed equivalent + if do_hflip: + if keypoint_hflip_indices is None: + raise ValueError("Cannot flip keypoints without providing flip indices!") + if len(keypoints) != len(keypoint_hflip_indices): + raise ValueError( + "Keypoint data has {} points, but metadata contains {} points!".format( + len(keypoints), len(keypoint_hflip_indices) + ) + ) + keypoints = keypoints[np.asarray(keypoint_hflip_indices, dtype=np.int32), :] + + # Maintain COCO convention that if visibility == 0 (unlabeled), then x, y = 0 + keypoints[keypoints[:, 2] == 0] = 0 + return keypoints + + +def annotations_to_instances(annos, image_size): + """ + Create an :class:`Instances` object used by the models, + from instance annotations in the dataset dict. + + Args: + annos (list[dict]): a list of instance annotations in one image, each + element for one instance. + image_size (tuple): height, width + + Returns: + Instances: + It will contain fields "gt_boxes", "gt_classes", + "gt_masks", "gt_keypoints", if they can be obtained from `annos`. + This is the format that builtin models expect. + """ + boxes = ( + np.stack([BoxMode.convert(obj["bbox"], obj["bbox_mode"], BoxMode.XYXY_ABS) for obj in annos]) + if len(annos) + else np.zeros((0, 4)) + ) + boxes = Boxes(torch.from_numpy(boxes)) + + classes = [int(obj["category_id"]) for obj in annos] + classes = torch.tensor(classes, dtype=torch.int64) + + masks = None + if len(annos) and "segmentation" in annos[0]: + segms = [obj["segmentation"] for obj in annos] + masks = [] + for segm in segms: + if isinstance(segm, list): + # polygon + masks.append(polygons_to_bitmask(segm, *image_size)) + elif isinstance(segm, dict): + # COCO RLE + masks.append(mask_util.decode(segm)) # type: ignore + elif isinstance(segm, np.ndarray): + assert segm.ndim == 2, "Expect segmentation of 2 dimensions, got {}.".format(segm.ndim) + # mask array + # recreating the np.array avoids a warning about non copiable stuff + masks.append(np.array(segm)) + else: + raise ValueError( + "Cannot convert segmentation of type '{}' to BitMasks!" + "Supported types are: polygons as list[list[float] or ndarray]," + " COCO-style RLE as a dict, or a binary segmentation mask " + " in a 2D numpy array of shape HxW.".format(type(segm)) + ) + # torch.from_numpy does not support array with negative stride. + masks = BitMasks(torch.stack([torch.from_numpy(np.ascontiguousarray(x)) for x in masks])) + + keypoints = None + if len(annos) and "keypoints" in annos[0]: + kpts = [obj.get("keypoints", []) for obj in annos] + keypoints = Keypoints(kpts) + + return Instances(image_size, boxes=boxes, classes=classes, masks=masks, keypoints=keypoints) + + +def filter_empty_instances( + instances: Instances, by_box: bool = True, by_mask: bool = True, box_threshold: float = 1e-5 +) -> Instances: + """ + Filter out empty instances in an `Instances` object. + + Args: + instances (Instances): + by_box (bool): whether to filter out instances with empty boxes + by_mask (bool): whether to filter out instances with empty masks + box_threshold (float): minimum width and height to be considered non-empty + return_mask (bool): whether to return boolean mask of filtered instances + + Returns: + Instances: the filtered instances. + tensor[bool], optional: boolean mask of filtered instances + """ + assert by_box or by_mask + r = [] + if instances.boxes and by_box: + r.append(instances.boxes.nonempty(threshold=box_threshold)) + if instances.masks and by_mask: + assert isinstance(instances.masks, BitMasks), "Error, masks in instances are not BitMasks" + r.append(instances.masks.nonempty()) + # TODO: can also filter visible keypoints + + if not r: + return instances + m = r[0] + for x in r[1:]: + m = m & x + + return instances[m] + + +def convert_PIL_to_numpy(image, format): + """ + Convert PIL image to numpy array of target format. + + Args: + image (PIL.Image): a PIL image + format (str): the format of output image + + Returns: + (np.ndarray): also see `read_image` + """ + if format is not None: + # PIL only supports RGB, so convert to RGB and flip channels over below + conversion_format = format + if format in ["BGR", "YUV-BT.601"]: + conversion_format = "RGB" + image = image.convert(conversion_format) + image = np.asarray(image) + # PIL squeezes out the channel dimension for "L", so make it HWC + if format == "L": + image = np.expand_dims(image, -1) + + # handle formats not supported by PIL + elif format == "BGR": + # flip channels if needed + image = image[:, :, ::-1] + elif format == "YUV-BT.601": + image = image / 255.0 + image = np.dot(image, np.array(_M_RGB2YUV).T) + + return image + + +def _apply_exif_orientation(image): + """ + Applies the exif orientation correctly. + + This code exists per the bug: + https://github.com/python-pillow/Pillow/issues/3973 + with the function `ImageOps.exif_transpose`. The Pillow source raises errors with + various methods, especially `tobytes` + + Function based on: + https://github.com/wkentaro/labelme/blob/v4.5.4/labelme/utils/image.py#L59 + https://github.com/python-pillow/Pillow/blob/7.1.2/src/PIL/ImageOps.py#L527 + + Args: + image (PIL.Image): a PIL image + + Returns: + (PIL.Image): the PIL image with exif orientation applied, if applicable + """ + # https://www.exiv2.org/tags.html + _EXIF_ORIENT = 274 # exif 'Orientation' tag + + if not hasattr(image, "getexif"): + return image + + try: + exif = image.getexif() + except Exception: # https://github.com/facebookresearch/detectron2/issues/1885 + exif = None + + if exif is None: + return image + + orientation = exif.get(_EXIF_ORIENT) + + method = { + 2: Image.FLIP_LEFT_RIGHT, + 3: Image.ROTATE_180, + 4: Image.FLIP_TOP_BOTTOM, + 5: Image.TRANSPOSE, + 6: Image.ROTATE_270, + 7: Image.TRANSVERSE, + 8: Image.ROTATE_90, + }.get(orientation) + + if method is not None: + return image.transpose(method) + return image + + +def read_image(file_name, format=None): + """ + Read an image into the given format. + Will apply rotation and flipping if the image has such exif information. + + Args: + file_name (str): image file path + format (str): one of the supported image modes in PIL, or "BGR" or "YUV-BT.601". + + Returns: + image (np.ndarray): + an HWC image in the given format, which is 0-255, uint8 for + supported image modes in PIL or "BGR"; float (0-1 for Y) for YUV-BT.601. + """ + with open(file_name, "rb") as f: + image = Image.open(f) + + # work around this bug: https://github.com/python-pillow/Pillow/issues/3973 + image = _apply_exif_orientation(image) + return convert_PIL_to_numpy(image, format) diff --git a/focoos/hub/__init__.py b/focoos/hub/__init__.py new file mode 100644 index 00000000..97b86b8e --- /dev/null +++ b/focoos/hub/__init__.py @@ -0,0 +1,6 @@ +from .api_client import ApiClient +from .focoos_hub import FocoosHUB +from .remote_dataset import RemoteDataset +from .remote_model import RemoteModel + +__all__ = ["FocoosHUB", "RemoteDataset", "RemoteModel", "ApiClient"] diff --git a/focoos/utils/api_client.py b/focoos/hub/api_client.py similarity index 81% rename from focoos/utils/api_client.py rename to focoos/hub/api_client.py index 1c9efdcf..a10c15d1 100644 --- a/focoos/utils/api_client.py +++ b/focoos/hub/api_client.py @@ -7,8 +7,9 @@ from focoos.config import FOCOOS_CONFIG from focoos.utils.logger import get_logger +from focoos.utils.system import get_focoos_version -logger = get_logger(__name__) +logger = get_logger("HUB") class ApiClient: @@ -26,8 +27,8 @@ class ApiClient: def __init__( self, - api_key: Optional[str] = FOCOOS_CONFIG.focoos_api_key, - host_url: Optional[str] = FOCOOS_CONFIG.default_host_url, + api_key: Optional[str] = None, + host_url: Optional[str] = None, ): """ Initialize the ApiClient with an API key and host URL. @@ -36,19 +37,19 @@ def __init__( api_key (str): The API key for authorization. host_url (str): The base URL for the API. """ - if not api_key: - raise ValueError("API key is required") - if not host_url: - raise ValueError("Host URL is required") - - self.api_key = api_key - self.host_url = host_url + # Use provided api_key if not None, otherwise use config + self.api_key = api_key if api_key is not None else FOCOOS_CONFIG.focoos_api_key + self.host_url = host_url or FOCOOS_CONFIG.default_host_url self.default_headers = { - "Authorization": f"Bearer {self.api_key}", - "user_agent": "focoos/0.0.1", + "X-API-Key": self.api_key, + "user_agent": f"focoos/{get_focoos_version()}", } + def _check_api_key(self): + if not self.api_key or (isinstance(self.api_key, str) and self.api_key.strip() == ""): + raise ValueError("API key is required") + def external_get(self, path: str, params: Optional[dict] = None, stream: bool = False): """ Perform a GET request to an external URL. @@ -84,6 +85,7 @@ def get( Returns: Response: The response object from the requests library. """ + self._check_api_key() url = f"{self.host_url}/{path}" headers = self.default_headers.copy() if extra_headers: @@ -109,12 +111,26 @@ def post( Returns: Response: The response object from the requests library. """ + self._check_api_key() url = f"{self.host_url}/{path}" headers = self.default_headers.copy() if extra_headers: headers.update(extra_headers) return requests.post(url, headers=headers, json=data, files=files) + def patch( + self, + path: str, + data: Optional[dict] = None, + extra_headers: Optional[dict] = None, + ): + self._check_api_key() + url = f"{self.host_url}/{path}" + headers = self.default_headers.copy() + if extra_headers: + headers.update(extra_headers) + return requests.patch(url, headers=headers, json=data) + def external_post( self, path: str, @@ -154,6 +170,7 @@ def delete(self, path: str, extra_headers: Optional[dict] = None): Returns: Response: The response object from the requests library. """ + self._check_api_key() url = f"{self.host_url}/{path}" headers = self.default_headers.copy() if extra_headers: @@ -172,9 +189,10 @@ def upload_file(self, path: str, file_path: str, file_size: int): Returns: Response: The response from the upload request """ + self._check_api_key() return self.post(path, data={"path": file_path, "file_size_bytes": file_size}) - def download_file(self, uri: str, file_dir: str): + def download_ext_file(self, uri: str, file_dir: str, file_name: Optional[str] = None, skip_if_exists: bool = False): """ Download a file from a URI to a local directory. @@ -191,21 +209,20 @@ def download_file(self, uri: str, file_dir: str): if os.path.exists(file_dir) and not os.path.isdir(file_dir): raise ValueError(f"Path is not a directory: {file_dir}") if not os.path.exists(file_dir): - logger.info(f"๐Ÿ“ฅ Creating directory: {file_dir}") + logger.debug(f"๐Ÿ“ฅ Creating directory: {file_dir}") os.makedirs(file_dir) parsed_url = urlparse(uri) - file_name = os.path.basename(parsed_url.path) + file_name = file_name or os.path.basename(parsed_url.path) res = self.external_get(uri, stream=True) if res.status_code != 200: logger.error(f"Failed to download file {file_name}: {res.status_code} {res.text}") raise ValueError(f"Failed to download file {file_name}: {res.status_code} {res.text}") - total_size = int(res.headers.get("content-length", 0)) - logger.info(f"๐Ÿ“ฅ Size: {total_size / (1024**2):.2f} MB") - if not os.path.exists(file_dir): - os.makedirs(file_dir) file_path = os.path.join(file_dir, file_name) - + if skip_if_exists and os.path.exists(file_path): + logger.debug(f"๐Ÿ“ฅ File already exists: {file_path}") + return file_path + total_size = int(res.headers.get("content-length", 0)) with ( open(file_path, "wb") as f, tqdm( @@ -219,5 +236,5 @@ def download_file(self, uri: str, file_dir: str): for chunk in res.iter_content(chunk_size=8192): f.write(chunk) bar.update(len(chunk)) - logger.info(f"๐Ÿ“ฅ File downloaded: {file_path}") + logger.debug(f"๐Ÿ“ฅ File downloaded: {file_path} Size: {total_size / (1024**2):.2f} MB") return file_path diff --git a/focoos/focoos.py b/focoos/hub/focoos_hub.py similarity index 52% rename from focoos/focoos.py rename to focoos/hub/focoos_hub.py index 7a396ebc..60054a0e 100644 --- a/focoos/focoos.py +++ b/focoos/hub/focoos_hub.py @@ -7,37 +7,35 @@ and listing shared datasets. Classes: - Focoos: Main class to interface with Focoos APIs. + FocoosHUB: Main class to interface with Focoos APIs. Exceptions: ValueError: Raised for invalid API responses or missing parameters. """ import os -from typing import Optional, Union +from dataclasses import asdict +from typing import Optional from focoos.config import FOCOOS_CONFIG -from focoos.local_model import LocalModel +from focoos.hub.api_client import ApiClient +from focoos.hub.remote_dataset import RemoteDataset +from focoos.hub.remote_model import RemoteModel from focoos.ports import ( - DatasetLayout, + MODELS_DIR, + ArtifactName, DatasetPreview, - FocoosTask, - ModelFormat, - ModelMetadata, - ModelNotFound, + ModelInfo, ModelPreview, - RuntimeTypes, + RemoteModelInfo, User, ) -from focoos.remote_dataset import RemoteDataset -from focoos.remote_model import RemoteModel -from focoos.utils.api_client import ApiClient -from focoos.utils.logger import setup_logging +from focoos.utils.logger import get_logger -logger = setup_logging() +logger = get_logger("HUB") -class Focoos: +class FocoosHUB: """ Main class to interface with Focoos APIs. @@ -48,8 +46,8 @@ class Focoos: Attributes: api_key (str): The API key for authentication. api_client (ApiClient): HTTP client for making API requests. - user_info (dict): Information about the currently authenticated user. - cache_dir (str): Local directory for caching downloaded models. + user_info (User): Information about the currently authenticated user. + host_url (str): Base URL for the Focoos API. """ def __init__( @@ -58,7 +56,7 @@ def __init__( host_url: Optional[str] = None, ): """ - Initializes the Focoos API client. + Initializes the FocoosHUB client. This client provides authenticated access to the Focoos API, enabling various operations through the configured HTTP client. It retrieves user information upon initialization and @@ -77,8 +75,8 @@ def __init__( Attributes: api_key (str): The API key used for authentication. api_client (ApiClient): An HTTP client instance configured with the API key and host URL. - user_info (dict): Information about the authenticated user retrieved from the API. - cache_dir (str): Path to the cache directory used by the client. + user_info (User): Information about the authenticated user retrieved from the API. + host_url (str): The base URL used for API requests. Logs: - Error if the API key or host URL is missing. @@ -86,9 +84,9 @@ def __init__( Example: ```python - from focoos import Focoos + from focoos import FocoosHUB - focoos = Focoos() + focoos = FocoosHUB() ``` """ self.api_key = api_key or FOCOOS_CONFIG.focoos_api_key @@ -100,7 +98,6 @@ def __init__( self.api_client = ApiClient(api_key=self.api_key, host_url=self.host_url) self.user_info = self.get_user_info() - self.cache_dir = os.path.join(os.path.expanduser("~"), ".cache", "focoos") logger.info(f"Currently logged as: {self.user_info.email} environment: {self.host_url}") def get_user_info(self) -> User: @@ -115,9 +112,9 @@ def get_user_info(self) -> User: Example: ```python - from focoos import Focoos + from focoos import FocoosHUB - focoos = Focoos() + focoos = FocoosHUB() user_info = focoos.get_user_info() # Access user info fields @@ -145,36 +142,36 @@ def get_user_info(self) -> User: raise ValueError(f"Failed to get user info: {res.status_code} {res.text}") return User.from_json(res.json()) - def get_model_info(self, model_ref: str) -> ModelMetadata: + def get_model_info(self, model_ref: str) -> RemoteModelInfo: """ Retrieves metadata for a specific model. Args: - model_ref (str): Name of the model. + model_ref (str): Reference identifier for the model. Returns: - ModelMetadata: Metadata of the specified model. + RemoteModelInfo: Metadata of the specified model. Raises: ValueError: If the API request fails. Example: ```python - from focoos import Focoos + from focoos import FocoosHUB - focoos = Focoos() - model_info = focoos.get_model_info(model_ref=) + focoos = FocoosHUB() + model_info = focoos.get_model_info(model_ref="user-or-fai-model-ref") ``` """ res = self.api_client.get(f"models/{model_ref}") if res.status_code != 200: logger.error(f"Failed to get model info: {res.status_code} {res.text}") raise ValueError(f"Failed to get model info: {res.status_code} {res.text}") - return ModelMetadata.from_json(res.json()) + return RemoteModelInfo.from_json(res.json()) - def list_models(self) -> list[ModelPreview]: + def list_remote_models(self) -> list[ModelPreview]: """ - Lists all User Models. + Lists all models owned by the user. Returns: list[ModelPreview]: List of model previews. @@ -184,10 +181,10 @@ def list_models(self) -> list[ModelPreview]: Example: ```python - from focoos import Focoos + from focoos import FocoosHUB - focoos = Focoos() - models = focoos.list_models() + focoos = FocoosHUB() + models = focoos.list_remote_models() ``` """ res = self.api_client.get("models/") @@ -196,227 +193,76 @@ def list_models(self) -> list[ModelPreview]: raise ValueError(f"Failed to list models: {res.status_code} {res.text}") return [ModelPreview.from_json(r) for r in res.json()] - def list_focoos_models(self) -> list[ModelPreview]: - """ - Lists FAI shared models. - - Returns: - list[ModelPreview]: List of Focoos models. - - Raises: - ValueError: If the API request fails. - - Example: - ```python - from focoos import Focoos - - focoos = Focoos() - focoos_models = focoos.list_focoos_models() - ``` - """ - res = self.api_client.get("models/focoos-models") - if res.status_code != 200: - logger.error(f"Failed to list focoos models: {res.status_code} {res.text}") - raise ValueError(f"Failed to list focoos models: {res.status_code} {res.text}") - return [ModelPreview.from_json(r) for r in res.json()] - - def get_local_model( - self, - model_ref: str, - runtime_type: Optional[RuntimeTypes] = RuntimeTypes.ONNX_CUDA32, - ) -> LocalModel: - """ - Retrieves a local model for the specified reference. - - Downloads the model if it does not already exist in the local cache. - - Args: - model_ref (str): Reference identifier for the model. - runtime_type (Optional[RuntimeTypes]): Runtime type for the model. Defaults to the - `runtime_type` specified in FOCOOS_CONFIG. - - Returns: - LocalModel: An instance of the local model. - - Raises: - ValueError: If the runtime type is not specified. - - Notes: - The model is cached in the directory specified by `self.cache_dir`. - - Example: - ```python - from focoos import Focoos - - focoos = Focoos() - model = focoos.get_local_model(model_ref=) - results, annotated_image = model.infer("image.jpg", threshold=0.5, annotate=True) # inference is local! - ``` - """ - runtime_type = runtime_type or FOCOOS_CONFIG.runtime_type - model_dir = os.path.join(self.cache_dir, model_ref) - format = ModelFormat.from_runtime_type(runtime_type) - if not os.path.exists(os.path.join(model_dir, f"model.{format.value}")): - self._download_model( - model_ref, - format=format, - ) - return LocalModel(model_dir, runtime_type) - def get_remote_model(self, model_ref: str) -> RemoteModel: """ - Retrieves a remote model instance. + Retrieves a remote model instance for cloud-based inference. Args: - model_ref (str): Reference name of the model. + model_ref (str): Reference identifier for the model. Returns: - RemoteModel: The remote model instance. + RemoteModel: The remote model instance configured for cloud-based inference. Example: ```python - from focoos import Focoos + from focoos import FocoosHUB - focoos = Focoos() - model = focoos.get_remote_model(model_ref=) - results, annotated_image = model.infer("image.jpg", threshold=0.5, annotate=True) # inference is remote! + focoos = FocoosHUB() + model = focoos.get_remote_model(model_ref="fai-model-ref") + results = model.infer("image.jpg", threshold=0.5) # inference is remote! ``` """ return RemoteModel(model_ref, self.api_client) - def new_model(self, name: str, focoos_model: str, description: str) -> RemoteModel: - """ - Creates a new model in the Focoos platform. - - Args: - name (str): Name of the new model. - focoos_model (str): Reference to the base Focoos model. - description (str): Description of the new model. - - Returns: - Optional[RemoteModel]: The created model instance, or None if creation fails. - - Raises: - ValueError: If the API request fails. - - Example: - ```python - from focoos import Focoos - - focoos = Focoos() - model = focoos.new_model(name="my-model", focoos_model="fai-model-ref", description="my-model-description") - ``` - """ - res = self.api_client.post( - "models/", - data={ - "name": name, - "focoos_model": focoos_model, - "description": description, - }, - ) - if res.status_code in [200, 201]: - return RemoteModel(res.json()["ref"], self.api_client) - if res.status_code == 409: - logger.warning(f"Model already exists: {name}") - return self.get_model_by_name(name, remote=True) - logger.warning(f"Failed to create new model: {res.status_code} {res.text}") - - def list_shared_datasets(self) -> list[DatasetPreview]: - """ - Lists datasets shared with the user. - - Returns: - list[DatasetPreview]: List of shared datasets. - - Raises: - ValueError: If the API request fails. - - Example: - ```python - from focoos import Focoos - - focoos = Focoos() - datasets = focoos.list_shared_datasets() - ``` - """ - res = self.api_client.get("datasets/shared") - if res.status_code != 200: - logger.error(f"Failed to list datasets: {res.status_code} {res.text}") - raise ValueError(f"Failed to list datasets: {res.status_code} {res.text}") - return [DatasetPreview.from_json(dataset) for dataset in res.json()] - - def _download_model(self, model_ref: str, format: ModelFormat = ModelFormat.ONNX) -> str: + def download_model_pth(self, model_ref: str, skip_if_exists: bool = True) -> str: """ Downloads a model from the Focoos API. Args: - model_ref (str): Reference name of the model. + model_ref (str): Reference identifier for the model. + skip_if_exists (bool): If True, skips the download if the model file already exists. + Defaults to True. Returns: - str: Path to the downloaded model. + str: Path to the downloaded model file. Raises: ValueError: If the API request fails or the download fails. """ - model_dir = os.path.join(self.cache_dir, model_ref) - model_path = os.path.join(model_dir, f"model.{format.value}") - metadata_path = os.path.join(model_dir, "focoos_metadata.json") - if os.path.exists(model_path) and os.path.exists(metadata_path): + model_dir = os.path.join(MODELS_DIR, model_ref) + model_pth_path = os.path.join(model_dir, ArtifactName.WEIGHTS) + if os.path.exists(model_pth_path) and skip_if_exists: logger.info("๐Ÿ“ฅ Model already downloaded") - return model_path + return model_pth_path if not os.path.exists(model_dir): os.makedirs(model_dir) ## download model metadata - res = self.api_client.get(f"models/{model_ref}/download?format={format.value}") + res = self.api_client.get(f"models/{model_ref}/download?format=pth") if res.status_code != 200: logger.error(f"Failed to retrieve download url for model: {res.status_code} {res.text}") raise ValueError(f"Failed to retrieve download url for model: {res.status_code} {res.text}") download_data = res.json() - download_uri = download_data["download_uri"] - + download_uri = download_data.get("download_uri") + if download_uri is None: + logger.error(f"Failed to retrieve download url for model: {res.status_code} {res.text}") + raise ValueError(f"Failed to retrieve download url for model: {res.status_code} {res.text}") ## download model from Focoos Cloud logger.debug(f"Model URI: {download_uri}") logger.info("๐Ÿ“ฅ Downloading model from Focoos Cloud.. ") try: - model_path = self.api_client.download_file(download_uri, model_dir) - metadata = ModelMetadata.from_json(download_data["model_metadata"]) - with open(metadata_path, "w") as f: - f.write(metadata.model_dump_json()) - logger.debug(f"Dumped metadata to {metadata_path}") + model_pth_path = self.api_client.download_ext_file(download_uri, model_dir, skip_if_exists=skip_if_exists) except Exception as e: logger.error(f"Failed to download model: {e}") raise ValueError(f"Failed to download model: {e}") - if model_path is None: + if model_pth_path is None: logger.error(f"Failed to download model: {res.status_code} {res.text}") raise ValueError(f"Failed to download model: {res.status_code} {res.text}") - return model_path + return model_pth_path - def get_model_by_name(self, name: str, remote: bool = True) -> Union[RemoteModel, LocalModel]: - """ - Retrieves a model by its name. - - Args: - name (str): Name of the model. - remote (bool): If True, retrieve as a RemoteModel. Otherwise, as a LocalModel. Defaults to True. - - Returns: - Optional[Union[RemoteModel, LocalModel]]: The model instance if found, or None otherwise. - """ - models = self.list_models() - name_lower = name.lower() - for model in models: - if name_lower == model.name.lower(): - if remote: - return self.get_remote_model(model.ref) - else: - return self.get_local_model(model.ref) - raise ModelNotFound(f"Model not found: {name}") - - def list_datasets(self, include_shared: bool = False) -> list[DatasetPreview]: + def list_remote_datasets(self, include_shared: bool = False) -> list[DatasetPreview]: """ Lists all datasets available to the user. @@ -435,15 +281,15 @@ def list_datasets(self, include_shared: bool = False) -> list[DatasetPreview]: Example: ```python - from focoos import Focoos + from focoos import FocoosHUB - focoos = Focoos() + focoos = FocoosHUB() # List only user's datasets - datasets = focoos.list_datasets() + datasets = focoos.list_remote_datasets() # List user's datasets and shared datasets - all_datasets = focoos.list_datasets(include_shared=True) + all_datasets = focoos.list_remote_datasets(include_shared=True) for dataset in all_datasets: print(f"Dataset: {dataset.name}, Task: {dataset.task}") @@ -462,55 +308,69 @@ def list_datasets(self, include_shared: bool = False) -> list[DatasetPreview]: datasets.extend([DatasetPreview.from_json(sh_dataset) for sh_dataset in res.json()]) return datasets - def add_remote_dataset(self, name: str, description: str, layout: DatasetLayout, task: FocoosTask) -> RemoteDataset: + def get_remote_dataset(self, ref: str) -> RemoteDataset: """ - Creates a new user dataset with the specified parameters. + Retrieves a remote dataset by its reference ID. Args: - name (str): The name of the dataset. - description (str): A description of the dataset. - layout (DatasetLayout): The layout structure of the dataset. - task (FocoosTask): The task type associated with the dataset. + ref (str): The reference ID of the dataset to retrieve. Returns: - RemoteDataset: A RemoteDataset instance representing the newly created dataset. - - Raises: - ValueError: If the dataset creation fails due to API errors. + RemoteDataset: A RemoteDataset instance for the specified reference. Example: ```python - from focoos import Focoos + from focoos import FocoosHUB - focoos = Focoos() - dataset = focoos.add_remote_dataset(name="my-dataset", description="my-dataset-description", layout=DatasetLayout.ROBOFLOW_COCO, task=FocoosTask.DETECTION) + focoos = FocoosHUB() + dataset = focoos.get_remote_dataset(ref="my-dataset-ref") ``` """ - res = self.api_client.post( - "datasets/", data={"name": name, "description": description, "layout": layout.value, "task": task.value} - ) - if res.status_code != 200: - logger.error(f"Failed to add dataset: {res.status_code} {res.text}") - raise ValueError(f"Failed to add dataset: {res.status_code} {res.text}") - logger.info(f"Remote Dataset created: {res.json()['ref']}") - return RemoteDataset(res.json()["ref"], self.api_client) + return RemoteDataset(ref, self.api_client) - def get_remote_dataset(self, ref: str) -> RemoteDataset: + def new_model(self, model_info: ModelInfo) -> RemoteModel: """ - Retrieves a remote dataset by its reference ID. + Creates a new model in the Focoos platform. Args: - ref (str): The reference ID of the dataset to retrieve. + name (str): Name of the new model. + focoos_model (str): Reference to the base Focoos model. + description (str): Description of the new model. Returns: - RemoteDataset: A RemoteDataset instance for the specified reference. + Optional[RemoteModel]: The created model instance, or None if creation fails. + + Raises: + ValueError: If the API request fails. Example: ```python from focoos import Focoos focoos = Focoos() - dataset = focoos.get_remote_dataset(ref="my-dataset-ref") + model = focoos.new_model(name="my-model", focoos_model="fai-model-ref", description="my-model-description") ``` """ - return RemoteDataset(ref, self.api_client) + + res = self.api_client.post( + "models/local-model", + data={ + "name": model_info.name, + "focoos_model": model_info.focoos_model, + "description": model_info.description, + "config": model_info.config if model_info.config else {}, + "task": model_info.task, + "classes": model_info.classes, + "im_size": model_info.im_size, + "train_args": asdict(model_info.train_args) if model_info.train_args else None, + "focoos_version": model_info.focoos_version, + }, + ) + if res.status_code in [200, 201]: + return RemoteModel(res.json()["ref"], self.api_client) + if res.status_code == 409: + logger.warning(f"Model already exists: {model_info.name}") + raise ValueError(f"Failed to create new model: {res.status_code} {res.text}") + else: + logger.warning(f"Failed to create new model: {res.status_code} {res.text}") + raise ValueError(f"Failed to create new model: {res.status_code} {res.text}") diff --git a/focoos/remote_dataset.py b/focoos/hub/remote_dataset.py similarity index 71% rename from focoos/remote_dataset.py rename to focoos/hub/remote_dataset.py index eefb34e4..22a7fd0e 100644 --- a/focoos/remote_dataset.py +++ b/focoos/hub/remote_dataset.py @@ -1,8 +1,8 @@ import os from typing import Optional -from focoos.ports import DatasetPreview, DatasetSpec -from focoos.utils.api_client import ApiClient +from focoos.hub.api_client import ApiClient +from focoos.ports import DATASETS_DIR, DatasetPreview, DatasetSpec from focoos.utils.logger import get_logger logger = get_logger(__name__) @@ -38,6 +38,8 @@ def get_info(self) -> DatasetPreview: DatasetPreview: The dataset preview information. """ res = self.api_client.get(f"datasets/{self.ref}") + if res.status_code != 200: + raise ValueError(f"Failed to get dataset info: {res.status_code} {res.text}") return DatasetPreview.from_json(res.json()) def upload_data(self, path: str) -> Optional[DatasetSpec]: @@ -72,14 +74,18 @@ def upload_data(self, path: str) -> Optional[DatasetSpec]: presigned_url = presigned_url.json() fields = {k: v for k, v in presigned_url["fields"].items()} logger.info(f"๐Ÿ“ค Uploading file {file_name}..") - fields["file"] = (file_name, open(path, "rb"), "application/zip") - res = self.api_client.external_post( - presigned_url["url"], - files=fields, - data=presigned_url["fields"], - stream=True, - ) + # Use context manager to properly handle file closure + with open(path, "rb") as file_obj: + fields["file"] = (file_name, file_obj, "application/zip") + + res = self.api_client.external_post( + presigned_url["url"], + files=fields, + data=presigned_url["fields"], + stream=True, + ) + logger.info("โœ… Upload file done.") if res.status_code not in [200, 201, 204]: raise ValueError(f"Failed to upload dataset: {res.status_code} {res.text}") @@ -94,7 +100,19 @@ def upload_data(self, path: str) -> Optional[DatasetSpec]: logger.info(f"โœ… Dataset validated! => {self.metadata.spec}") return self.metadata.spec - def download_data(self, path: str): + @property + def name(self): + return self.metadata.name + + @property + def task(self): + return self.metadata.task + + @property + def layout(self): + return self.metadata.layout + + def download_data(self, path: str = DATASETS_DIR): """ Downloads the dataset data to a local path. @@ -110,39 +128,11 @@ def download_data(self, path: str): res = self.api_client.get(f"datasets/{self.ref}/download") if res.status_code != 200: raise ValueError(f"Failed to download dataset data: {res.status_code} {res.text}") - logger.info(f"๐Ÿ“ฅ Downloading dataset data to {path}") url = res.json()["download_uri"] - path = self.api_client.download_file(url, path) + + path = self.api_client.download_ext_file(url, path, skip_if_exists=True) logger.info(f"โœ… Dataset data downloaded to {path}") return path - def delete(self): - """ - Deletes the entire dataset from the remote storage. - - Raises: - Exception: If the deletion fails. - """ - try: - res = self.api_client.delete(f"datasets/{self.ref}") - res.raise_for_status() - logger.warning(f"Deleted dataset {self.ref}") - except Exception as e: - logger.error(f"Failed to delete dataset {self.ref}: {e}") - raise e - - def delete_data(self): - """ - Deletes only the data content of the dataset while preserving metadata. - - Updates the metadata after successful deletion. - """ - try: - res = self.api_client.delete(f"datasets/{self.ref}/data") - - res.raise_for_status() - new_metadata = DatasetPreview.from_json(res.json()) - self.metadata = new_metadata - logger.warning(f"Deleted dataset data {self.ref}") - except Exception as e: - logger.error(f"Failed to delete dataset data {self.ref}: {e}") + def __str__(self): + return f"RemoteDataset(ref={self.ref}, name={self.name}, task={self.task}, layout={self.layout})" diff --git a/focoos/remote_model.py b/focoos/hub/remote_model.py similarity index 54% rename from focoos/remote_model.py rename to focoos/hub/remote_model.py index 2ce60a78..ba598552 100644 --- a/focoos/remote_model.py +++ b/focoos/hub/remote_model.py @@ -19,29 +19,28 @@ import os import time +from dataclasses import asdict from pathlib import Path from time import sleep -from typing import Optional, Tuple, Union +from typing import List, Optional, Union import cv2 import numpy as np -import supervision as sv +from PIL import Image +from focoos.hub.api_client import ApiClient from focoos.ports import ( + ArtifactName, FocoosDet, FocoosDetections, - FocoosTask, - Hyperparameters, + HubSyncLocalTraining, Metrics, - ModelMetadata, ModelStatus, + RemoteModelInfo, TrainingInfo, - TrainInstance, ) -from focoos.utils.api_client import ApiClient from focoos.utils.logger import get_logger -from focoos.utils.metrics import MetricsVisualizer -from focoos.utils.vision import fai_detections_to_sv, image_loader +from focoos.utils.metrics import MetricsVisualizer, parse_metrics logger = get_logger() @@ -53,11 +52,7 @@ class RemoteModel: Attributes: model_ref (str): Reference ID for the model. api_client (ApiClient): Client for making HTTP requests. - max_deploy_wait (int): Maximum wait time for model deployment. - metadata (ModelMetadata): Metadata of the model. - label_annotator (LabelAnnotator): Annotator for adding labels to images. - box_annotator (sv.BoxAnnotator): Annotator for drawing bounding boxes. - mask_annotator (sv.MaskAnnotator): Annotator for drawing masks on images. + model_info (RemoteModelInfo): Model information of the model. """ def __init__( @@ -77,16 +72,17 @@ def __init__( """ self.model_ref = model_ref self.api_client = api_client - self.metadata: ModelMetadata = self.get_info() + self.model_info: RemoteModelInfo = self.get_info() - self.label_annotator = sv.LabelAnnotator(text_padding=10, border_radius=10) - self.box_annotator = sv.BoxAnnotator() - self.mask_annotator = sv.MaskAnnotator() logger.info( - f"[RemoteModel]: ref: {self.model_ref} name: {self.metadata.name} description: {self.metadata.description} status: {self.metadata.status}" + f"[RemoteModel]: ref: {self.model_ref} name: {self.model_info.name} description: {self.model_info.description} status: {self.model_info.status}" ) - def get_info(self) -> ModelMetadata: + @property + def ref(self) -> str: + return self.model_ref + + def get_info(self) -> RemoteModelInfo: """ Retrieve model metadata. @@ -109,51 +105,70 @@ def get_info(self) -> ModelMetadata: if res.status_code != 200: logger.error(f"Failed to get model info: {res.status_code} {res.text}") raise ValueError(f"Failed to get model info: {res.status_code} {res.text}") - self.metadata = ModelMetadata(**res.json()) + self.metadata = RemoteModelInfo(**res.json()) return self.metadata - def train( - self, - dataset_ref: str, - hyperparameters: Hyperparameters, - instance_type: TrainInstance = TrainInstance.ML_G4DN_XLARGE, - volume_size: int = 50, - max_runtime_in_seconds: int = 36000, - ) -> dict | None: - """ - Initiate the training of a remote model on the Focoos platform. - - This method sends a request to the Focoos platform to start the training process for the model - referenced by `self.model_ref`. It requires a dataset reference and hyperparameters for training, - as well as optional configuration options for the instance type, volume size, and runtime. - - Args: - dataset_ref (str): The reference ID of the dataset to be used for training. - hyperparameters (Hyperparameters): A structure containing the hyperparameters for the training process. - instance_type (TrainInstance, optional): The type of training instance to use. Defaults to TrainInstance.ML_G4DN_XLARGE. - volume_size (int, optional): The size of the disk volume (in GB) for the training instance. Defaults to 50. - max_runtime_in_seconds (int, optional): The maximum runtime for training in seconds. Defaults to 36000. - - Returns: - dict: A dictionary containing the response from the training initiation request. The content depends on the Focoos platform's response. + def sync_local_training_job( + self, local_training_info: HubSyncLocalTraining, dir: str, upload_artifacts: Optional[List[ArtifactName]] = None + ) -> None: + if not os.path.exists(os.path.join(dir, ArtifactName.INFO)): + logger.warning(f"Model info not found in {dir}") + raise ValueError(f"Model info not found in {dir}") + metrics = parse_metrics(os.path.join(dir, ArtifactName.METRICS)) + local_training_info.metrics = metrics + logger.debug( + f"[Syncing Training] iter: {metrics.iterations} {self.metadata.name} status: {local_training_info.status} ref: {self.model_ref}" + ) - Raises: - ValueError: If the request to start training fails (e.g., due to incorrect parameters or server issues). - """ - res = self.api_client.post( - f"models/{self.model_ref}/train", - data={ - "dataset_ref": dataset_ref, - "instance_type": instance_type, - "volume_size": volume_size, - "max_runtime_in_seconds": max_runtime_in_seconds, - "hyperparameters": hyperparameters.model_dump(), - }, + ## Update metrics + res = self.api_client.patch( + f"models/{self.model_ref}/sync-local-training", + data=asdict(local_training_info), ) if res.status_code != 200: - logger.warning(f"Failed to train model: {res.status_code} {res.text}") - raise ValueError(f"Failed to train model: {res.status_code} {res.text}") - return res.json() + logger.error(f"Failed to sync local training: {res.status_code} {res.text}") + raise ValueError(f"Failed to sync local training: {res.status_code} {res.text}") + if upload_artifacts: + for artifact in [ArtifactName.METRICS, ArtifactName.WEIGHTS]: + file_path = os.path.join(dir, artifact.value) + if os.path.isfile(file_path): + try: + self._upload_model_artifact(file_path) + except Exception: + logger.error(f"Failed to upload artifact: {artifact.value}") + pass + + def _upload_model_artifact(self, path: str) -> None: + """ + Uploads an model artifact to the Focoos platform. + """ + if not os.path.exists(path): + raise ValueError(f"File not found: {path}") + file_ext = os.path.splitext(path)[1] + if file_ext not in [".pt", ".onnx", ".pth", ".json", ".txt"]: + raise ValueError(f"Unsupported file extension: {file_ext}") + file_name = os.path.basename(path) + file_size = os.path.getsize(path) + file_size_mb = file_size / (1024 * 1024) + logger.debug(f"๐Ÿ”— Requesting upload url for {file_name} of size {file_size_mb:.2f} MB") + presigned_url = self.api_client.post( + f"models/{self.model_ref}/generate-upload-url", + data={"file_size_bytes": file_size, "file_name": file_name}, + ) + if presigned_url.status_code != 200: + raise ValueError(f"Failed to generate upload url: {presigned_url.status_code} {presigned_url.text}") + presigned_url = presigned_url.json() + fields = {k: v for k, v in presigned_url["fields"].items()} + logger.info(f"๐Ÿ“ค Uploading file {file_name} to HUB, size: {file_size_mb:.2f} MB") + fields["file"] = (file_name, open(path, "rb")) + res = self.api_client.external_post( + presigned_url["url"], + files=fields, + data=presigned_url["fields"], + ) + if res.status_code not in [200, 201, 204]: + raise ValueError(f"Failed to upload model artifact: {res.status_code} {res.text}") + logger.info(f"โœ… Model artifact {file_name} uploaded to HUB.") def train_info(self) -> Optional[TrainingInfo]: """ @@ -169,9 +184,10 @@ def train_info(self) -> Optional[TrainingInfo]: """ res = self.api_client.get(f"models/{self.model_ref}/train/status") if res.status_code != 200: - logger.error(f"Failed to get train status: {res.status_code} {res.text}") - raise ValueError(f"Failed to get train status: {res.status_code} {res.text}") - return TrainingInfo(**res.json()) + logger.error(f"Failed to get train info: {res.status_code} {res.text}") + raise ValueError(f"Failed to get train info: {res.status_code} {res.text}") + dct = {k: v for k, v in res.json().items() if k in TrainingInfo.__dataclass_fields__} + return TrainingInfo(**dct) def train_logs(self) -> list[str]: """ @@ -211,75 +227,32 @@ def metrics(self) -> Metrics: # noqa: F821 if res.status_code != 200: logger.warning(f"Failed to get metrics: {res.status_code} {res.text}") return Metrics() # noqa: F821 - return Metrics(**res.json()) - - def _annotate(self, im: np.ndarray, detections: sv.Detections) -> np.ndarray: - """ - Annotate an image with detection results. - - This method adds visual annotations to the provided image based on the model's detection results. - It handles different tasks (e.g., object detection, semantic segmentation, instance segmentation) - and uses the corresponding annotator (bounding box, label, or mask) to draw on the image. - - Args: - im (np.ndarray): The image to be annotated, represented as a NumPy array. - detections (sv.Detections): The detection results to be annotated, including class IDs and confidence scores. - - Returns: - np.ndarray: The annotated image as a NumPy array. - """ + return Metrics(**{k: v for k, v in res.json().items() if k in Metrics.__dataclass_fields__}) - if len(detections.xyxy) == 0: - logger.warning("No detections found, skipping annotation") - return im - classes = self.metadata.classes - if classes is not None: - labels = [ - f"{classes[int(class_id)]}: {confid * 100:.0f}%" - for class_id, confid in zip(detections.class_id, detections.confidence) - ] - else: - labels = [ - f"{str(class_id)}: {confid * 100:.0f}%" - for class_id, confid in zip(detections.class_id, detections.confidence) - ] - if self.metadata.task == FocoosTask.DETECTION: - annotated_im = self.box_annotator.annotate(scene=im.copy(), detections=detections) - - annotated_im = self.label_annotator.annotate(scene=annotated_im, detections=detections, labels=labels) - elif self.metadata.task in [ - FocoosTask.SEMSEG, - FocoosTask.INSTANCE_SEGMENTATION, - ]: - annotated_im = self.mask_annotator.annotate(scene=im.copy(), detections=detections) - return annotated_im + def __call__( + self, image: Union[str, Path, np.ndarray, bytes, Image.Image], threshold: float = 0.5 + ) -> FocoosDetections: + return self.infer(image, threshold) def infer( self, - image: Union[str, Path, np.ndarray, bytes], + image: Union[str, Path, np.ndarray, bytes, Image.Image], threshold: float = 0.5, - annotate: bool = False, - ) -> Tuple[FocoosDetections, Optional[np.ndarray]]: + ) -> FocoosDetections: """ Perform inference on the provided image using the remote model. This method sends an image to the remote model for inference and retrieves the detection results. - Optionally, it can annotate the image with the detection results. Args: image (Union[str, Path, np.ndarray, bytes]): The image to infer on, which can be a file path, a string representing the path, a NumPy array, or raw bytes. threshold (float, optional): The confidence threshold for detections. Defaults to 0.5. Detections with confidence scores below this threshold will be discarded. - annotate (bool, optional): Whether to annotate the image with the detection results. Defaults to False. - If set to True, the method will return the image with bounding boxes or segmentation masks. Returns: - Tuple[FocoosDetections, Optional[np.ndarray]]: - - FocoosDetections: The detection results including class IDs, confidence scores, bounding boxes, - and segmentation masks (if applicable). - - Optional[np.ndarray]: The annotated image if `annotate` is True, else None. - This will be a NumPy array representation of the image with drawn bounding boxes or segmentation masks. + FocoosDetections: The detection results including class IDs, confidence scores, bounding boxes, + and segmentation masks (if applicable). Raises: FileNotFoundError: If the provided image file path is invalid. @@ -290,9 +263,8 @@ def infer( from focoos import Focoos focoos = Focoos() - model = focoos.get_remote_model("my-model") - results, annotated_image = model.infer("image.jpg", threshold=0.5, annotate=True) + results = model.infer("image.jpg", threshold=0.5) # Print detection results for det in results.detections: @@ -302,6 +274,9 @@ def infer( print("Instance segmentation mask included") ``` """ + if isinstance(image, Image.Image): + image = np.array(image) + image_bytes = None if isinstance(image, str) or isinstance(image, Path): if not os.path.exists(image): @@ -328,12 +303,8 @@ def infer( logger.debug( f"Found {len(detections.detections)} detections. Inference Request time: {(t1 - t0) * 1000:.0f}ms" ) - preview = None - if annotate: - im0 = image_loader(image) - sv_detections = fai_detections_to_sv(detections, im0.shape[:-1]) - preview = self._annotate(im0, sv_detections) - return detections, preview + + return detections else: logger.error(f"Failed to infer: {res.status_code} {res.text}") raise ValueError(f"Failed to infer: {res.status_code} {res.text}") @@ -404,46 +375,3 @@ def notebook_monitor_train(self, interval: int = 30, plot_metrics: bool = False, return sleep(interval) - - def stop_training(self) -> None: - """ - Stop the training process of the model. - - This method sends a request to stop the training of the model identified by `model_ref`. - If the request fails, an error is logged and a `ValueError` is raised. - - Raises: - ValueError: If the stop training request fails. - - Logs: - - Error message if the request to stop training fails, including the status code and response text. - - Returns: - None: This method does not return any value. - """ - res = self.api_client.delete(f"models/{self.model_ref}/train") - if res.status_code != 200: - logger.error(f"Failed to get stop training: {res.status_code} {res.text}") - raise ValueError(f"Failed to get stop training: {res.status_code} {res.text}") - - def delete_model(self) -> None: - """ - Delete the model from the system. - - This method sends a request to delete the model identified by `model_ref`. - If the request fails or the status code is not 204 (No Content), an error is logged - and a `ValueError` is raised. - - Raises: - ValueError: If the delete model request fails or does not return a 204 status code. - - Logs: - - Error message if the request to delete the model fails, including the status code and response text. - - Returns: - None: This method does not return any value. - """ - res = self.api_client.delete(f"models/{self.model_ref}") - if res.status_code != 204: - logger.error(f"Failed to delete model: {res.status_code} {res.text}") - raise ValueError(f"Failed to delete model: {res.status_code} {res.text}") diff --git a/focoos/infer/__init__.py b/focoos/infer/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/focoos/local_model.py b/focoos/infer/infer_model.py similarity index 55% rename from focoos/local_model.py rename to focoos/infer/infer_model.py index c848a92f..cc58b292 100644 --- a/focoos/local_model.py +++ b/focoos/infer/infer_model.py @@ -1,15 +1,16 @@ """ -LocalModel Module +InferModel Module -This module provides the `LocalModel` class that allows loading, inference, +This module provides the `InferModel` class that allows loading, inference, and benchmark testing of models in a local environment. It supports detection -and segmentation tasks, and utilizes ONNXRuntime for model execution. +and segmentation tasks, and utilizes various runtime backends including ONNXRuntime +and TorchScript for model execution. Classes: - LocalModel: A class for managing and interacting with local models. + InferModel: A class for managing and interacting with local models. Methods: - __init__: Initializes the LocalModel instance, loading the model, metadata, + __init__: Initializes the InferModel instance, loading the model, metadata, and setting up the runtime. _read_metadata: Reads the model metadata from a JSON file. _annotate: Annotates the input image with detection or segmentation results. @@ -28,30 +29,29 @@ from PIL import Image from focoos.config import FOCOOS_CONFIG +from focoos.infer.runtimes.base import BaseRuntime +from focoos.infer.runtimes.load_runtime import load_runtime from focoos.ports import ( FocoosDetections, - FocoosTask, LatencyMetrics, - ModelFormat, - ModelMetadata, - RuntimeTypes, + ModelExtension, + ModelInfo, + RuntimeType, ) -from focoos.runtime import BaseRuntime, load_runtime +from focoos.processor.processor_manager import ProcessorManager from focoos.utils.logger import get_logger from focoos.utils.vision import ( - get_postprocess_fn, image_preprocess, - sv_to_fai_detections, ) -logger = get_logger(__name__) +logger = get_logger("InferModel") -class LocalModel: +class InferModel: def __init__( self, model_dir: Union[str, Path], - runtime_type: Optional[RuntimeTypes] = None, + runtime_type: Optional[RuntimeType] = None, ): """ Initialize a LocalModel instance. @@ -83,13 +83,14 @@ def __init__( and initializes the runtime for inference using the provided runtime type. Annotation utilities are also prepared for visualizing model outputs. """ + # Determine runtime type and model format runtime_type = runtime_type or FOCOOS_CONFIG.runtime_type - model_format = ModelFormat.from_runtime_type(runtime_type) + extension = ModelExtension.from_runtime_type(runtime_type) # Set model directory and path self.model_dir: Union[str, Path] = model_dir - self.model_path = os.path.join(model_dir, f"model.{model_format.value}") + self.model_path = os.path.join(model_dir, f"model.{extension.value}") logger.debug(f"Runtime type: {runtime_type}, Loading model from {self.model_path}..") # Check if model path exists @@ -97,9 +98,18 @@ def __init__( raise FileNotFoundError(f"Model path not found: {self.model_path}") # Load metadata and set model reference - self.metadata: ModelMetadata = self._read_metadata() - self.model_ref = self.metadata.ref - self.postprocess_fn = get_postprocess_fn(self.metadata.task) + # self.metadata: RemoteModelInfo = self._read_metadata() + + self.model_info: ModelInfo = self._read_model_info() + + try: + from focoos.model_manager import ConfigManager + + model_config = ConfigManager.from_dict(self.model_info.model_family, self.model_info.config) + self.processor = ProcessorManager.get_processor(self.model_info.model_family, model_config) + except Exception as e: + logger.error(f"Error creating model config: {e}") + raise e # Initialize annotation utilities self.label_annotator = sv.LabelAnnotator(text_padding=10, border_radius=10) @@ -110,59 +120,29 @@ def __init__( self.runtime: BaseRuntime = load_runtime( runtime_type, str(self.model_path), - self.metadata, + self.model_info, FOCOOS_CONFIG.warmup_iter, ) - def _read_metadata(self) -> ModelMetadata: - """ - Reads the model metadata from a JSON file. - - Returns: - ModelMetadata: Metadata for the model. - - Raises: - FileNotFoundError: If the metadata file does not exist in the model directory. + def _read_model_info(self) -> ModelInfo: """ - metadata_path = os.path.join(self.model_dir, "focoos_metadata.json") - return ModelMetadata.from_json(metadata_path) - - def _annotate(self, im: np.ndarray, detections: sv.Detections) -> np.ndarray: + Reads the model info from a JSON file. """ - Annotates the input image with detection or segmentation results. + model_info_path = os.path.join(self.model_dir, "model_info.json") + if not os.path.exists(model_info_path): + raise FileNotFoundError(f"Model info file not found: {model_info_path}") + return ModelInfo.from_json(model_info_path) - Args: - im (np.ndarray): The input image to annotate. - detections (sv.Detections): Detected objects or segmented regions. - - Returns: - np.ndarray: The annotated image with bounding boxes or masks. - """ - if len(detections.xyxy) == 0: - logger.warning("No detections found, skipping annotation") - return im - classes = self.metadata.classes - labels = [ - f"{classes[int(class_id)] if classes is not None else str(class_id)}: {confid * 100:.0f}%" - for class_id, confid in zip(detections.class_id, detections.confidence) # type: ignore - ] - if self.metadata.task == FocoosTask.DETECTION: - annotated_im = self.box_annotator.annotate(scene=im.copy(), detections=detections) - - annotated_im = self.label_annotator.annotate(scene=annotated_im, detections=detections, labels=labels) - elif self.metadata.task in [ - FocoosTask.SEMSEG, - FocoosTask.INSTANCE_SEGMENTATION, - ]: - annotated_im = self.mask_annotator.annotate(scene=im.copy(), detections=detections) - return annotated_im + def __call__( + self, image: Union[bytes, str, Path, np.ndarray, Image.Image], threshold: Optional[float] = None + ) -> FocoosDetections: + return self.infer(image, threshold) def infer( self, image: Union[bytes, str, Path, np.ndarray, Image.Image], - threshold: float = 0.5, - annotate: bool = False, - ) -> Tuple[FocoosDetections, Optional[np.ndarray]]: + threshold: Optional[float] = None, + ) -> FocoosDetections: """ Run inference on an input image and optionally annotate the results. @@ -171,16 +151,9 @@ def infer( This can be a byte array, file path, or a PIL Image object, or a NumPy array representing the image. threshold (float, optional): The confidence threshold for detections. Defaults to 0.5. Detections with confidence scores below this threshold will be discarded. - annotate (bool, optional): Whether to annotate the image with detection results. Defaults to False. - If set to True, the method will return the image with bounding boxes or segmentation masks. Returns: - Tuple[FocoosDetections, Optional[np.ndarray]]: A tuple containing: - - `FocoosDetections`: The detections from the inference, represented as a custom object (`FocoosDetections`). - This includes the details of the detected objects such as class, confidence score, and bounding box (if applicable). - - `Optional[np.ndarray]`: The annotated image, if `annotate=True`. - This will be a NumPy array representation of the image with drawn bounding boxes or segmentation masks. - If `annotate=False`, this value will be `None`. + FocoosDetections: The detections from the inference, represented as a custom object (`FocoosDetections`). Raises: ValueError: If the model is not deployed locally (i.e., `self.runtime` is `None`). @@ -191,42 +164,50 @@ def infer( focoos = Focoos() model = focoos.get_local_model(model_ref="") - detections, annotated_image = model.infer(image, threshold=0.5, annotate=True) + detections = model.infer(image, threshold=0.5) ``` """ assert self.runtime is not None, "Model is not deployed (locally)" - resize = None #!TODO check for segmentation - if self.metadata.task == FocoosTask.DETECTION: - resize = 640 if not self.metadata.im_size else self.metadata.im_size t0 = perf_counter() - im1, im0 = image_preprocess(image, resize=resize) - logger.debug(f"Input image size: {im0.shape}, Resize to: {resize}") + im1, im0 = image_preprocess(image) + tensors, _ = self.processor.preprocess(inputs=im1, device="cuda", image_size=self.model_info.im_size) + logger.debug(f"Input image size: {im0.shape}") t1 = perf_counter() - detections = self.runtime(im1.astype(np.float32)) - t2 = perf_counter() + raw_detections = self.runtime(tensors) - detections = self.postprocess_fn( - out=detections, im0_shape=(im0.shape[0], im0.shape[1]), conf_threshold=threshold + t2 = perf_counter() + detections = self.processor.export_postprocess( + raw_detections, im0, threshold=threshold, class_names=self.model_info.classes ) - out = sv_to_fai_detections(detections, classes=self.metadata.classes) t3 = perf_counter() latency = { "inference": round(t2 - t1, 3), "preprocess": round(t1 - t0, 3), "postprocess": round(t3 - t2, 3), } - im = None - if annotate: - im = self._annotate(im0, detections) + res = detections[0] #!TODO check for batching + res.latency = latency logger.debug( - f"Found {len(detections)} detections. Inference time: {(t2 - t1) * 1000:.0f}ms, preprocess: {(t1 - t0) * 1000:.0f}ms, postprocess: {(t3 - t2) * 1000:.0f}ms" + f"Found {len(res)} detections. Inference time: {(t2 - t1) * 1000:.0f}ms, preprocess: {(t1 - t0) * 1000:.0f}ms, postprocess: {(t3 - t2) * 1000:.0f}ms" ) - return FocoosDetections(detections=out, latency=latency), im + return res - def benchmark(self, iterations: int, size: int) -> LatencyMetrics: + def benchmark(self, iterations: int = 50, size: Optional[Union[int, Tuple[int, int]]] = None) -> LatencyMetrics: + """ + Benchmark the model's inference performance over multiple iterations. + """ + if size is None: + size = self.model_info.im_size + if isinstance(size, int): + size = (size, size) + return self.runtime.benchmark(iterations, size) + + def end2end_benchmark( + self, iterations: int = 50, size: Optional[Union[int, Tuple[int, int]]] = None + ) -> LatencyMetrics: """ Benchmark the model's inference performance over multiple iterations. @@ -243,7 +224,7 @@ def benchmark(self, iterations: int, size: int) -> LatencyMetrics: focoos = Focoos() model = focoos.get_local_model(model_ref="") - metrics = model.benchmark(iterations=10, size=640) + metrics = model.end2end_benchmark(iterations=10, size=640) # Access latency metrics print(f"FPS: {metrics.fps}") @@ -253,4 +234,36 @@ def benchmark(self, iterations: int, size: int) -> LatencyMetrics: print(f"Input size: {metrics.im_size}x{metrics.im_size}") ``` """ - return self.runtime.benchmark(iterations, size) + if size is None: + size = self.model_info.im_size + if isinstance(size, int): + size = (size, size) + + engine, device = self.runtime.get_info() + logger.info(f"โฑ๏ธ Benchmarking latency on {device}, size: {size}x{size}..") + + np_input = (255 * np.random.random((size[0], size[1], 3))).astype(np.uint8) + + durations = [] + for step in range(iterations + 5): + start = perf_counter() + self(np_input) + end = perf_counter() + + if step >= 5: # Skip first 5 iterations + durations.append((end - start) * 1000) + + durations = np.array(durations) + + metrics = LatencyMetrics( + fps=int(1000 / durations.mean()), + engine=engine, + mean=round(durations.mean().astype(float), 3), + max=round(durations.max().astype(float), 3), + min=round(durations.min().astype(float), 3), + std=round(durations.std().astype(float), 3), + im_size=size[0], # FIXME: this is a hack to get the im_size as int, assuming it's a square + device=device, + ) + logger.info(f"๐Ÿ”ฅ FPS: {metrics.fps} Mean latency: {metrics.mean} ms ") + return metrics diff --git a/focoos/infer/runtimes/__init__.py b/focoos/infer/runtimes/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/focoos/infer/runtimes/base.py b/focoos/infer/runtimes/base.py new file mode 100644 index 00000000..55db6540 --- /dev/null +++ b/focoos/infer/runtimes/base.py @@ -0,0 +1,66 @@ +from abc import abstractmethod +from typing import Any, Tuple, Union + +import numpy as np +import torch + +from focoos.ports import LatencyMetrics, RemoteModelInfo + + +class BaseRuntime: + """ + Abstract base class for runtime implementations. + + This class defines the interface that all runtime implementations must follow. + It provides methods for model initialization, inference, and performance benchmarking. + + Attributes: + model_path (str): Path to the model file. + opts (Any): Runtime-specific options. + model_info (RemoteModelInfo): Metadata about the model. + """ + + def __init__(self, model_path: str, opts: Any, model_info: RemoteModelInfo): + """ + Initialize the runtime with model path, options and metadata. + + Args: + model_path (str): Path to the model file. + opts (Any): Runtime-specific configuration options. + model_info (RemoteModelInfo): Metadata about the model. + """ + pass + + @abstractmethod + def __call__(self, im: torch.Tensor) -> list[np.ndarray]: + """ + Run inference on the input image. + + Args: + im (np.ndarray): Input image as a numpy array. + + Returns: + np.ndarray: Model output as a numpy array. + """ + pass + + @abstractmethod + def get_info(self) -> tuple[str, str]: + """ + Get the engine and device name. + """ + pass + + @abstractmethod + def benchmark(self, iterations: int, size: Union[int, Tuple[int, int]]) -> LatencyMetrics: + """ + Benchmark the model performance. + + Args: + iterations (int): Number of inference iterations to run. + size (float): Input image size for benchmarking. + + Returns: + LatencyMetrics: Performance metrics including mean, median, and percentile latencies. + """ + pass diff --git a/focoos/infer/runtimes/load_runtime.py b/focoos/infer/runtimes/load_runtime.py new file mode 100644 index 00000000..f71ac1b2 --- /dev/null +++ b/focoos/infer/runtimes/load_runtime.py @@ -0,0 +1,77 @@ +from focoos.infer.runtimes.base import BaseRuntime +from focoos.ports import ModelInfo, OnnxRuntimeOpts, RuntimeType, TorchscriptRuntimeOpts +from focoos.utils.logger import get_logger + +try: + import torch # noqa: F401 + + TORCH_AVAILABLE = True +except ImportError: + TORCH_AVAILABLE = False + +try: + import onnxruntime as ort # noqa: F401 + + ORT_AVAILABLE = True +except ImportError: + ORT_AVAILABLE = False + + +logger = get_logger() + + +def load_runtime( + runtime_type: RuntimeType, + model_path: str, + model_info: ModelInfo, + warmup_iter: int = 50, +) -> BaseRuntime: + """ + Creates and returns a runtime instance based on the specified runtime type. + Supports both ONNX and TorchScript runtimes with various execution providers. + + Args: + runtime_type (RuntimeTypes): The type of runtime to use. Can be one of: + - ONNX_CUDA32: ONNX runtime with CUDA FP32 + - ONNX_TRT32: ONNX runtime with TensorRT FP32 + - ONNX_TRT16: ONNX runtime with TensorRT FP16 + - ONNX_CPU: ONNX runtime with CPU + - ONNX_COREML: ONNX runtime with CoreML + - TORCHSCRIPT_32: TorchScript runtime with FP32 + model_path (str): Path to the model file (.onnx or .pt) + model_metadata (ModelMetadata): Model metadata containing task type, classes etc. + warmup_iter (int, optional): Number of warmup iterations before inference. Defaults to 0. + + Returns: + BaseRuntime: A configured runtime instance (ONNXRuntime or TorchscriptRuntime) + + Raises: + ImportError: If required dependencies (torch/onnxruntime) are not installed + """ + if runtime_type == RuntimeType.TORCHSCRIPT_32: + if not TORCH_AVAILABLE: + logger.error( + "โš ๏ธ Pytorch not found =( please install focoos with ['torch'] extra. See https://focoosai.github.io/focoos/setup/ for more details" + ) + raise ImportError("Pytorch not found") + from focoos.infer.runtimes.torchscript import TorchscriptRuntime + + opts = TorchscriptRuntimeOpts(warmup_iter=warmup_iter) + return TorchscriptRuntime(model_path=model_path, opts=opts, model_info=model_info) + else: + if not ORT_AVAILABLE: + logger.error( + "โš ๏ธ onnxruntime not found =( please install focoos with one of 'onnx', 'onnx-cpu', extra. See https://focoosai.github.io/focoos/setup/ for more details" + ) + raise ImportError("onnxruntime not found") + from focoos.infer.runtimes.onnx import ONNXRuntime + + opts = OnnxRuntimeOpts( + cuda=runtime_type == RuntimeType.ONNX_CUDA32, + trt=runtime_type in [RuntimeType.ONNX_TRT32, RuntimeType.ONNX_TRT16], + fp16=runtime_type == RuntimeType.ONNX_TRT16, + warmup_iter=warmup_iter, + coreml=runtime_type == RuntimeType.ONNX_COREML, + verbose=False, + ) + return ONNXRuntime(model_path=model_path, opts=opts, model_info=model_info) diff --git a/focoos/infer/runtimes/onnx.py b/focoos/infer/runtimes/onnx.py new file mode 100644 index 00000000..610b7afb --- /dev/null +++ b/focoos/infer/runtimes/onnx.py @@ -0,0 +1,211 @@ +from pathlib import Path +from time import perf_counter +from typing import Tuple, Union + +import numpy as np +import onnxruntime as ort +import torch + +# from supervision.detection.utils import mask_to_xyxy +from focoos.infer.runtimes.base import BaseRuntime +from focoos.ports import ( + LatencyMetrics, + ModelInfo, + OnnxRuntimeOpts, +) +from focoos.utils.logger import get_logger +from focoos.utils.system import get_cpu_name, get_gpu_info + +GPU_ID = 0 + +logger = get_logger("ONNXRuntime") + + +class ONNXRuntime(BaseRuntime): + """ + ONNX Runtime wrapper for model inference with different execution providers. + + This class implements the BaseRuntime interface for ONNX models, supporting + various execution providers like CUDA, TensorRT, OpenVINO, and CoreML. + It handles model initialization, provider configuration, warmup, inference, + and performance benchmarking. + + Attributes: + name (str): Name of the model derived from the model path. + opts (OnnxRuntimeOpts): Configuration options for the ONNX runtime. + model_info (RemoteModelInfo): Metadata about the model. + ort_sess (ort.InferenceSession): ONNX Runtime inference session. + active_providers (list): List of active execution providers. + dtype (np.dtype): Input data type for the model. + """ + + def __init__(self, model_path: Union[str, Path], opts: OnnxRuntimeOpts, model_info: ModelInfo): + logger.debug(f"๐Ÿ”ง [onnxruntime device] {ort.get_device()}") + + self.name = Path(model_path).stem + self.opts = opts + self.model_info = model_info + + # Setup session options + options = ort.SessionOptions() + options.log_severity_level = 0 if opts.verbose else 2 + options.enable_profiling = opts.verbose + + # Setup providers + self.providers = self._setup_providers(model_dir=Path(model_path).parent) + self.active_provider = self.providers[0][0] + logger.info(f" using: {self.active_provider}") + # Create session + self.ort_sess = ort.InferenceSession(model_path, options, providers=self.providers) + + if self.opts.trt and self.providers[0][0] == "TensorrtExecutionProvider": + logger.info("๐ŸŸข TensorRT enabled. First execution may take longer as it builds the TRT engine.") + # Set input type + self.dtype = np.uint8 if self.ort_sess.get_inputs()[0].type == "tensor(uint8)" else np.float32 + + # Warmup + if self.opts.warmup_iter > 0: + self._warmup() + + # inputs = self.ort_sess.get_inputs() + # outputs = self.ort_sess.get_outputs() + # for input in inputs: + # logger.debug(f"๐Ÿ”ง Input: {input.name} {input.type} {input.shape}") + # for output in outputs: + # logger.debug(f"๐Ÿ”ง Output: {output.name} {output.type} {output.shape}") + + def _setup_providers(self, model_dir: Path): + providers = [] + available = ort.get_available_providers() + logger.debug(f"Available providers:{available}") + _dir = Path(model_dir) + models_root = _dir.parent + # Check and add providers in order of preference + provider_configs = [ + ( + "TensorrtExecutionProvider", + self.opts.trt, + { + "device_id": GPU_ID, + "trt_fp16_enable": self.opts.fp16, + "trt_force_sequential_engine_build": False, + "trt_engine_cache_enable": True, + "trt_engine_cache_path": str(_dir / ".trt_cache"), + "trt_ep_context_file_path": str(_dir), + "trt_timing_cache_enable": True, # Timing cache can be shared across multiple models if layers are the same + "trt_builder_optimization_level": 3, + "trt_timing_cache_path": str(models_root / ".trt_timing_cache"), + }, + ), + ( + "OpenVINOExecutionProvider", + self.opts.vino, + {"device_type": "MYRIAD_FP16", "enable_vpu_fast_compile": True, "num_of_threads": 1}, + ), + ( + "CUDAExecutionProvider", + self.opts.cuda, + { + "device_id": GPU_ID, + "arena_extend_strategy": "kSameAsRequested", + "gpu_mem_limit": 16 * 1024 * 1024 * 1024, + "cudnn_conv_algo_search": "EXHAUSTIVE", + "do_copy_in_default_stream": True, + }, + ), + ("CoreMLExecutionProvider", self.opts.coreml, {}), + ] + + for provider, enabled, config in provider_configs: + if enabled and provider in available: + providers.append((provider, config)) + elif enabled: + logger.warning(f"{provider} not found.") + + providers.append(("CPUExecutionProvider", {})) + return providers + + def _warmup(self): + size = self.model_info.im_size + logger.info(f"โฑ๏ธ Warming up model {self.name} on {self.active_provider}, size: {size}x{size}..") + np_image = np.random.rand(1, 3, size, size).astype(self.dtype) + input_name = self.ort_sess.get_inputs()[0].name + out_name = [output.name for output in self.ort_sess.get_outputs()] + + for _ in range(self.opts.warmup_iter): + self.ort_sess.run(out_name, {input_name: np_image}) + + logger.info("โฑ๏ธ Warmup done") + + def __call__(self, im: torch.Tensor) -> list[np.ndarray]: + """ + Run inference on the input image. + + Args: + im (np.ndarray): Input image as a numpy array. + + Returns: + list[np.ndarray]: Model outputs as a list of numpy arrays. + """ + input_name = self.ort_sess.get_inputs()[0].name + out_name = [output.name for output in self.ort_sess.get_outputs()] + out = self.ort_sess.run(out_name, {input_name: im.cpu().numpy()}) + return out + + def get_info(self) -> tuple[str, str]: + gpu_info = get_gpu_info() + device_name = "CPU" + if gpu_info.devices is not None and len(gpu_info.devices) > 0: + device_name = gpu_info.devices[0].gpu_name + else: + device_name = get_cpu_name() + logger.warning(f"No GPU found, using CPU {device_name}.") + return f"onnx.{self.active_provider}", str(device_name) + + def benchmark(self, iterations: int = 50, size: Union[int, Tuple[int, int]] = 640) -> LatencyMetrics: + """ + Benchmark the model performance. + + Runs multiple inference iterations and measures execution time to calculate + performance metrics like FPS, mean latency, and other statistics. + + Args: + iterations (int, optional): Number of inference iterations to run. Defaults to 20. + size (int or tuple, optional): Input image size for benchmarking. Defaults to 640. + + Returns: + LatencyMetrics: Performance metrics including FPS, mean, min, max, and std latencies. + """ + engine, device_name = self.get_info() + if isinstance(size, int): + size = (size, size) + + logger.info(f"โฑ๏ธ Benchmarking latency on {device_name}, size: {size}x{size}..") + + np_input = (255 * np.random.random((1, 3, size[0], size[1]))).astype(self.dtype) + input_name = self.ort_sess.get_inputs()[0].name + out_name = [output.name for output in self.ort_sess.get_outputs()] + + durations = [] + for step in range(iterations + 5): + start = perf_counter() + self.ort_sess.run(out_name, {input_name: np_input}) + end = perf_counter() + + if step >= 5: # Skip first 5 iterations + durations.append((end - start) * 1000) + + durations = np.array(durations) + + metrics = LatencyMetrics( + fps=int(1000 / durations.mean()), + engine=engine, + mean=round(durations.mean().astype(float), 3), + max=round(durations.max().astype(float), 3), + min=round(durations.min().astype(float), 3), + std=round(durations.std().astype(float), 3), + im_size=size[0], # FIXME: this is a hack to get the im_size as int, assuming it's a square + device=device_name, + ) + logger.info(f"๐Ÿ”ฅ FPS: {metrics.fps} Mean latency: {metrics.mean} ms ") + return metrics diff --git a/focoos/infer/runtimes/torchscript.py b/focoos/infer/runtimes/torchscript.py new file mode 100644 index 00000000..27a9ab47 --- /dev/null +++ b/focoos/infer/runtimes/torchscript.py @@ -0,0 +1,126 @@ +from time import perf_counter +from typing import Tuple, Union + +import numpy as np +import torch + +from focoos.infer.runtimes.base import BaseRuntime +from focoos.ports import LatencyMetrics, ModelInfo, Task, TorchscriptRuntimeOpts +from focoos.utils.logger import get_logger +from focoos.utils.system import get_cpu_name, get_gpu_info + +logger = get_logger("TorchscriptRuntime") + + +class TorchscriptRuntime(BaseRuntime): + """ + TorchScript Runtime wrapper for model inference. + + This class implements the BaseRuntime interface for TorchScript models, + supporting both CPU and CUDA devices. It handles model initialization, + device placement, warmup, inference, and performance benchmarking. + + Attributes: + device (torch.device): Device to run inference on (CPU or CUDA). + opts (TorchscriptRuntimeOpts): Configuration options for the TorchScript runtime. + model (torch.jit.ScriptModule): Loaded TorchScript model. + model_info (RemoteModelInfo): Metadata about the model. + """ + + def __init__( + self, + model_path: str, + opts: TorchscriptRuntimeOpts, + model_info: ModelInfo, + ): + self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu") + logger.info(f"๐Ÿ”ง Device: {self.device}") + self.opts = opts + self.model_info = model_info + + map_location = None if torch.cuda.is_available() else "cpu" + + self.model = torch.jit.load(model_path, map_location=map_location) + self.model = self.model.to(self.device) + + if self.opts.warmup_iter > 0: + size = ( + self.model_info.im_size if self.model_info.task == Task.DETECTION and self.model_info.im_size else 640 + ) + logger.info(f"โฑ๏ธ Warming up model {self.model_info.name} on {self.device}, size: {size}x{size}..") + with torch.no_grad(): + np_image = torch.rand(1, 3, size, size, device=self.device) + for _ in range(self.opts.warmup_iter): + self.model(np_image) + logger.info("โฑ๏ธ WARMUP DONE") + + def __call__(self, im: torch.Tensor) -> list[np.ndarray]: + """ + Run inference on the input image. + + Args: + im (np.ndarray): Input image as a numpy array. + + Returns: + list[np.ndarray]: Model outputs as a list of numpy arrays. + """ + with torch.no_grad(): + res = self.model(im) + return res + + def get_info(self) -> tuple[str, str]: + gpu_info = get_gpu_info() + device_name = "CPU" + if gpu_info.devices is not None and len(gpu_info.devices) > 0: + device_name = gpu_info.devices[0].gpu_name + else: + device_name = get_cpu_name() + logger.warning(f"No GPU found, using CPU {device_name}.") + + return "torchscript", str(device_name) + + def benchmark(self, iterations: int = 20, size: Union[int, Tuple[int, int]] = 640) -> LatencyMetrics: + """ + Benchmark the model performance. + + Runs multiple inference iterations and measures execution time to calculate + performance metrics like FPS, mean latency, and other statistics. + + Args: + iterations (int, optional): Number of inference iterations to run. Defaults to 20. + + Returns: + LatencyMetrics: Performance metrics including FPS, mean, min, max, and std latencies. + """ + engine, device_name = self.get_info() + logger.info(f"โฑ๏ธ Benchmarking latency on {device_name}, size: {size}x{size}..") + + if isinstance(size, int): + size = (size, size) + + torch_input = torch.rand(1, 3, size[0], size[1], device=self.device) + durations = [] + + with torch.no_grad(): + for step in range(iterations + 5): + start = perf_counter() + self.model(torch_input) + end = perf_counter() + + if step >= 5: # Skip first 5 iterations + durations.append((end - start) * 1000) + + durations = np.array(durations) + + metrics = LatencyMetrics( + fps=int(1000 / durations.mean().astype(float)), + engine=engine, + mean=round(durations.mean().astype(float), 3), + max=round(durations.max().astype(float), 3), + min=round(durations.min().astype(float), 3), + std=round(durations.std().astype(float), 3), + im_size=size, + device=device_name, + ) + logger.info(f"๐Ÿ”ฅ FPS: {metrics.fps} Mean latency: {metrics.mean} ms ") + return metrics diff --git a/focoos/model_manager.py b/focoos/model_manager.py new file mode 100644 index 00000000..3dc9db5e --- /dev/null +++ b/focoos/model_manager.py @@ -0,0 +1,459 @@ +import importlib +import os +from dataclasses import fields +from typing import Callable, Dict, Optional, Tuple, Type + +from focoos.hub.focoos_hub import FocoosHUB +from focoos.model_registry.model_registry import ModelRegistry +from focoos.models.focoos_model import BaseModelNN, FocoosModel +from focoos.nn.backbone.base import BackboneConfig, BaseBackbone +from focoos.ports import MODELS_DIR, ArtifactName, ModelConfig, ModelFamily, ModelInfo +from focoos.utils.logger import get_logger + +logger = get_logger("ModelManager") + + +class ModelManager: + """Automatic model manager with lazy loading. + + The ModelManager provides a unified interface for loading models from various sources: + - From ModelInfo objects + - From the Focoos Hub (hub:// protocol) + - From local directories + - From the model registry + + It handles model registration, configuration management, and weights loading automatically. + Models are loaded lazily when requested and can be accessed through the `get` method. + + Examples: + Load a registered model: + >>> model = ModelManager.get("model_name") + + Load a model from hub: + >>> model = ModelManager.get("hub://username/model_ref") + + Load a model with custom config: + >>> model = ModelManager.get("model_name", config=custom_config) + """ + + _models_family_map: Dict[str, Callable[[], Type[BaseModelNN]]] = {} # {"fai-detr": load_fai_detr()} + + @classmethod + def get( + cls, + name: str, + model_info: Optional[ModelInfo] = None, + config: Optional[ModelConfig] = None, + models_dir: Optional[str] = None, + hub: Optional[FocoosHUB] = None, + cache: bool = True, + **kwargs, + ) -> FocoosModel: + """ + Unified entrypoint to load a model by name or ModelInfo. + + This method provides a single interface for loading models from various sources: + - From a ModelInfo object (when model_info is provided) + - From the Focoos Hub (when name starts with "hub://") + - From the ModelRegistry (for pretrained models) + - From a local directory (when name is a local path) + + Args: + name: Model name, path, or hub reference (e.g., "hub://username/model_ref") + model_info: Optional ModelInfo object to load the model from directly + config: Optional custom model configuration to override defaults + models_dir: Optional directory to look for local models (defaults to MODELS_DIR) + hub: Optional FocoosHUB instance to use for hub:// references + cache: Optional boolean to cache the model info and weights when loading from hub (defaults to True) + **kwargs: Additional keyword arguments passed to the model configuration + + Returns: + FocoosModel: The loaded model instance + + Raises: + ValueError: If the model cannot be found or loaded + """ + if model_info is not None: + # Load model directly from provided ModelInfo + return cls._from_model_info(model_info=model_info, config=config, **kwargs) + + # If name starts with "hub://", load from Focoos Hub + if name.startswith("hub://"): + model_info, hub_config = cls._from_hub(hub_uri=name, hub=hub, cache=cache, **kwargs) + if config is None: + config = hub_config # Use hub config if no config is provided + # If model exists in ModelRegistry, load as pretrained model + elif ModelRegistry.exists(name): + model_info = ModelRegistry.get_model_info(name) + # Otherwise, attempt to load from a local directory + else: + model_info = cls._from_local_dir(name=name, models_dir=models_dir) + # Load model from the resolved ModelInfo + return cls._from_model_info(model_info=model_info, config=config, **kwargs) + + @classmethod + def register_model(cls, model_family: ModelFamily, model_loader: Callable[[], Type[BaseModelNN]]): + """ + Register a loader for a specific model family. + + This method associates a model family with a loader function that returns + the model class when called. This enables lazy loading of model classes. + + Args: + model_family: The ModelFamily enum value to register + model_loader: A callable that returns the model class when invoked + """ + cls._models_family_map[model_family.value] = model_loader + + @classmethod + def _ensure_family_registered(cls, model_family: ModelFamily): + """ + Ensure the model family is registered, importing if needed. + + This method checks if a model family is registered and if not, attempts to + import and register it automatically by calling any registration functions + in the family module. + + Args: + model_family: The ModelFamily enum value to ensure is registered + """ + if model_family.value in cls._models_family_map: + return + family_module = importlib.import_module(f"focoos.models.{model_family.value}") + for attr_name in dir(family_module): + if attr_name.startswith("_register"): + register_func = getattr(family_module, attr_name) + if callable(register_func): + register_func() + + @classmethod + def _from_model_info(cls, model_info: ModelInfo, config: Optional[ModelConfig] = None, **kwargs) -> FocoosModel: + """ + Load a model from ModelInfo, handling config and weights. + + This method instantiates a model based on the ModelInfo, applying the provided + configuration (or using the one from ModelInfo) and loading weights if available. + + Args: + model_info: ModelInfo object containing model metadata and references + config: Optional model configuration to override the one in ModelInfo + **kwargs: Additional keyword arguments passed to the model configuration + + Returns: + FocoosModel: The instantiated model with weights loaded if available + + Raises: + ValueError: If the model family is not supported + """ + cls._ensure_family_registered(model_info.model_family) + if model_info.model_family.value not in cls._models_family_map: + raise ValueError(f"Model {model_info.model_family} not supported") + model_class = cls._models_family_map[model_info.model_family.value]() + config = config or ConfigManager.from_dict(model_info.model_family, model_info.config, **kwargs) + model_info.config = config + nn_model = model_class(model_info.config) + model = FocoosModel(nn_model, model_info) + return model + + @classmethod + def _from_local_dir(cls, name: str, models_dir: Optional[str] = None) -> ModelInfo: + """ + Load a model from a local experiment directory. + + This method loads a model from a local directory by reading its ModelInfo file + and resolving paths to weights and other artifacts. + + Args: + name: Name or path of the model directory relative to models_dir + models_dir: Base directory containing model directories (defaults to MODELS_DIR) + + Returns: + ModelInfo: The model information loaded from the local directory + + Raises: + ValueError: If the model directory or ModelInfo file cannot be found + """ + models_dir = models_dir or MODELS_DIR + + run_dir = os.path.join(models_dir, name) + if not os.path.exists(run_dir): + raise ValueError(f"Run {name} not found in {models_dir}") + model_info_path = os.path.join(run_dir, ArtifactName.INFO) + if not os.path.exists(model_info_path): + raise ValueError(f"Model info not found in {run_dir}") + model_info = ModelInfo.from_json(model_info_path) + + if model_info.weights_uri == ArtifactName.WEIGHTS: + model_info.weights_uri = os.path.join(run_dir, model_info.weights_uri) + + return model_info + + @classmethod + def _from_hub( + cls, hub_uri: str, hub: Optional[FocoosHUB] = None, cache: bool = True, **kwargs + ) -> Tuple[ModelInfo, ModelConfig]: + """ + Load a model from the Focoos Hub. + + This method downloads a model from the Focoos Hub using the provided URI, + which should be in the format "hub://username/model_ref". + + Args: + hub_uri: Hub URI in the format "hub://username/model_ref" + hub: Optional FocoosHUB instance to use (creates a new one if not provided) + **kwargs: Additional keyword arguments passed to the model configuration + + Returns: + Tuple[ModelInfo, ModelConfig]: The model information and configuration + + Raises: + ValueError: If the model reference is invalid or the model cannot be downloaded + """ + hub = hub or FocoosHUB() + model_ref = hub_uri.split("hub://")[1] + + if not model_ref: + raise ValueError("Model ref is required") + + model_pth_path = hub.download_model_pth(model_ref=model_ref, skip_if_exists=cache) + + model_info_path = os.path.join(MODELS_DIR, model_ref, ArtifactName.INFO) + if not os.path.exists(model_info_path) or not cache: + logger.info(f"๐Ÿ“ฅ Downloading model info from hub for model: {model_ref}") + remote_model_info = hub.get_model_info(model_ref=model_ref) + model_info = ModelInfo.from_json(remote_model_info.model_dump(mode="json")) + model_info.dump_json(model_info_path) + else: + logger.info(f"๐Ÿ“ฅ Loading model info from cache: {model_info_path}") + model_info = ModelInfo.from_json(model_info_path) + + if not model_info.weights_uri: + model_info.weights_uri = model_pth_path + + model_config = ConfigManager.from_dict(model_info.model_family, model_info.config, **kwargs) + + return (model_info, model_config) + + +class BackboneManager: + """ + Automatic backbone manager with lazy loading. + + The BackboneManager provides a unified interface for loading neural network backbones + (feature extractors) from their configurations. It supports multiple backbone architectures + like ResNet, STDC, Swin Transformer, MobileNetV2, and others. + + The manager maintains a mapping between backbone type names and their implementation paths, + and handles the dynamic loading of the appropriate classes. + """ + + _BACKBONE_MAPPING: Dict[str, str] = { + "resnet": "resnet.ResNet", + "stdc": "stdc.STDC", + "swin": "swin.Swin", + "mobilenet_v2": "mobilenet_v2.MobileNetV2", + "mit": "mit.MIT", + "convnextv2": "convnextv2.ConvNeXtV2", + } + + @classmethod + def from_config(cls, config: BackboneConfig) -> BaseBackbone: + """ + Load a backbone from a configuration. + + This method instantiates a backbone model based on the provided configuration, + dynamically loading the appropriate backbone class based on the model_type. + + Args: + config: The backbone configuration containing model_type and other parameters + + Returns: + BaseBackbone: The instantiated backbone model + + Raises: + ValueError: If the backbone type is not supported + """ + if config.model_type not in cls._BACKBONE_MAPPING: + raise ValueError(f"Backbone {config.model_type} not supported") + backbone_class = cls.get_model_class(config.model_type) + return backbone_class(config) + + @classmethod + def get_model_class(cls, model_type: str): + """ + Get the model class based on the model type. + + This method dynamically imports and returns the backbone class + corresponding to the specified model type. + + Args: + model_type: The type of backbone model to load (e.g., "resnet", "swin") + + Returns: + Type[BaseBackbone]: The backbone class + + Raises: + ImportError: If the module cannot be imported + AttributeError: If the class is not found in the module + """ + import importlib + + module_path, class_name = cls._BACKBONE_MAPPING[model_type].split(".") + module = importlib.import_module(f".{module_path}", package="focoos.nn.backbone") + return getattr(module, class_name) + + +class ConfigManager: + """ + Automatic model configuration management. + + The ConfigManager provides a centralized system for managing model configurations. + It maintains a registry of configuration classes for different model families and + handles the creation of appropriate configuration objects from dictionaries. + + The manager supports dynamic registration of configuration classes and automatic + importing of model family modules as needed. + """ + + _MODEL_CFG_MAPPING: Dict[str, Callable[[], Type[ModelConfig]]] = {} + + @classmethod + def register_config(cls, model_family: ModelFamily, model_config_loader: Callable[[], Type[ModelConfig]]): + """ + Register a loader for a specific model configuration. + + This method associates a model family with a loader function that returns + the configuration class when called. This enables lazy loading of configuration + classes. + + Args: + model_family: The ModelFamily enum value to register + model_config_loader: A callable that returns the configuration class when invoked + """ + cls._MODEL_CFG_MAPPING[model_family.value] = model_config_loader + + @classmethod + def from_dict(cls, model_family: ModelFamily, config_dict: dict, **kwargs) -> ModelConfig: + """ + Create a configuration from a dictionary. + + This method instantiates a model configuration object based on the model family + and the provided configuration dictionary. It handles nested configurations + like backbone_config and validates the parameters. + + Args: + model_family: The model family enum value + config_dict: Dictionary containing configuration parameters + **kwargs: Additional keyword arguments to override configuration values + + Returns: + ModelConfig: The instantiated configuration object + + Raises: + ValueError: If the model family is not supported or if invalid parameters are provided + """ + if model_family.value not in cls._MODEL_CFG_MAPPING: + # Import the family module + family_module = importlib.import_module(f"focoos.models.{model_family.value}") + + # Iteratively register all models in the family + for attr_name in dir(family_module): + if attr_name.startswith("_register"): + register_func = getattr(family_module, attr_name) + if callable(register_func): + register_func() + + if model_family.value not in cls._MODEL_CFG_MAPPING: + raise ValueError(f"Model {model_family} not supported") + + config_class = cls._MODEL_CFG_MAPPING[model_family.value]() # this return the config class + + # Convert the input dict to the actual config type + if "backbone_config" in config_dict and config_dict["backbone_config"] is not None: + config_dict["backbone_config"] = ConfigBackboneManager.from_dict(config_dict["backbone_config"]) + + # Validate the parameters kwargs + valid_fields = {f.name for f in fields(config_class)} + invalid_kwargs = set(kwargs.keys()) - valid_fields + if invalid_kwargs: + raise ValueError( + f"Invalid parameters for {config_class.__name__}: {invalid_kwargs}\nValid parameters: {valid_fields}" + ) + + config_dict = config_class(**config_dict) + + # Update the config with the kwargs + if kwargs: + config_dict.update(kwargs) + + return config_dict + + +class ConfigBackboneManager: + """ + Automatic backbone configuration manager with lazy loading. + + The ConfigBackboneManager provides a specialized manager for handling backbone + configurations. It maintains a mapping between backbone type names and their + configuration classes, and handles the dynamic loading of these classes. + + This manager is used primarily by the ConfigManager when processing nested + backbone configurations within model configurations. + """ + + _BACKBONE_MAPPING: Dict[str, str] = { + "resnet": "resnet.ResnetConfig", + "stdc": "stdc.STDCConfig", + "swin": "swin.SwinConfig", + "mobilenet_v2": "mobilenet_v2.MobileNetV2Config", + "mit": "mit.MITConfig", + "convnextv2": "convnextv2.ConvNeXtV2Config", + } + + @classmethod + def get_model_class(cls, model_type: str): + """ + Get the configuration class based on the model type. + + This method dynamically imports and returns the backbone configuration class + corresponding to the specified model type. + + Args: + model_type: The type of backbone model (e.g., "resnet", "swin") + + Returns: + Type[BackboneConfig]: The backbone configuration class + + Raises: + ImportError: If the module cannot be imported + AttributeError: If the class is not found in the module + """ + import importlib + + module_path, class_name = cls._BACKBONE_MAPPING[model_type].split(".") + module = importlib.import_module(f".{module_path}", package="focoos.nn.backbone") + return getattr(module, class_name) + + @classmethod + def from_dict(cls, config_dict: dict) -> BackboneConfig: + """ + Create a backbone configuration from a dictionary. + + This method instantiates a backbone configuration object based on the + model_type specified in the configuration dictionary. + + Args: + config_dict: Dictionary containing configuration parameters including model_type + + Returns: + BackboneConfig: The instantiated backbone configuration object + + Raises: + ValueError: If the backbone type is not supported + """ + if config_dict["model_type"] not in cls._BACKBONE_MAPPING: + raise ValueError(f"Backbone {config_dict['model_type']} not supported") + + config_class = cls.get_model_class(config_dict["model_type"]) + return_config = config_class(**config_dict) + return return_config diff --git a/focoos/model_registry/__init__.py b/focoos/model_registry/__init__.py new file mode 100644 index 00000000..cf2e1a18 --- /dev/null +++ b/focoos/model_registry/__init__.py @@ -0,0 +1,3 @@ +from .model_registry import ModelRegistry + +__all__ = ["ModelRegistry"] diff --git a/focoos/model_registry/bisenetformer-l-ade.json b/focoos/model_registry/bisenetformer-l-ade.json new file mode 100644 index 00000000..c195aef5 --- /dev/null +++ b/focoos/model_registry/bisenetformer-l-ade.json @@ -0,0 +1,543 @@ +{ + "name": "bisenetformer-l-ade", + "model_family": "bisenetformer", + "classes": [ + "wall", + "building", + "sky", + "floor", + "tree", + "ceiling", + "road, route", + "bed", + "window ", + "grass", + "cabinet", + "sidewalk, pavement", + "person", + "earth, ground", + "door", + "table", + "mountain, mount", + "plant", + "curtain", + "chair", + "car", + "water", + "painting, picture", + "sofa", + "shelf", + "house", + "sea", + "mirror", + "rug", + "field", + "armchair", + "seat", + "fence", + "desk", + "rock, stone", + "wardrobe, closet, press", + "lamp", + "tub", + "rail", + "cushion", + "base, pedestal, stand", + "box", + "column, pillar", + "signboard, sign", + "chest of drawers, chest, bureau, dresser", + "counter", + "sand", + "sink", + "skyscraper", + "fireplace", + "refrigerator, icebox", + "grandstand, covered stand", + "path", + "stairs", + "runway", + "case, display case, showcase, vitrine", + "pool table, billiard table, snooker table", + "pillow", + "screen door, screen", + "stairway, staircase", + "river", + "bridge, span", + "bookcase", + "blind, screen", + "coffee table", + "toilet, can, commode, crapper, pot, potty, stool, throne", + "flower", + "book", + "hill", + "bench", + "countertop", + "stove", + "palm, palm tree", + "kitchen island", + "computer", + "swivel chair", + "boat", + "bar", + "arcade machine", + "hovel, hut, hutch, shack, shanty", + "bus", + "towel", + "light", + "truck", + "tower", + "chandelier", + "awning, sunshade, sunblind", + "street lamp", + "booth", + "tv", + "plane", + "dirt track", + "clothes", + "pole", + "land, ground, soil", + "bannister, banister, balustrade, balusters, handrail", + "escalator, moving staircase, moving stairway", + "ottoman, pouf, pouffe, puff, hassock", + "bottle", + "buffet, counter, sideboard", + "poster, posting, placard, notice, bill, card", + "stage", + "van", + "ship", + "fountain", + "conveyer belt, conveyor belt, conveyer, conveyor, transporter", + "canopy", + "washer, automatic washer, washing machine", + "plaything, toy", + "pool", + "stool", + "barrel, cask", + "basket, handbasket", + "falls", + "tent", + "bag", + "minibike, motorbike", + "cradle", + "oven", + "ball", + "food, solid food", + "step, stair", + "tank, storage tank", + "trade name", + "microwave", + "pot", + "animal", + "bicycle", + "lake", + "dishwasher", + "screen", + "blanket, cover", + "sculpture", + "hood, exhaust hood", + "sconce", + "vase", + "traffic light", + "tray", + "trash can", + "fan", + "pier", + "crt screen", + "plate", + "monitor", + "bulletin board", + "shower", + "radiator", + "glass, drinking glass", + "clock", + "flag" + ], + "im_size": 640, + "task": "semseg", + "config": { + "num_classes": 150, + "backbone_config": { + "use_pretrained": false, + "backbone_url": null, + "model_type": "stdc", + "in_chans": 3, + "base": 64, + "layers": [ + 4, + 5, + 3 + ], + "out_features": [ + "res2", + "res3", + "res4", + "res5" + ], + "block_num": 4, + "block_type": "cat", + "use_conv_last": false + }, + "num_queries": 100, + "pixel_mean": [ + 123.675, + 116.28, + 103.53 + ], + "pixel_std": [ + 58.395, + 57.12, + 57.375 + ], + "size_divisibility": 0, + "pixel_decoder_out_dim": 128, + "pixel_decoder_feat_dim": 128, + "transformer_predictor_out_dim": 128, + "transformer_predictor_hidden_dim": 256, + "transformer_predictor_dec_layers": 6, + "transformer_predictor_dim_feedforward": 1024, + "head_out_dim": 128, + "cls_sigmoid": false, + "postprocessing_type": "semantic", + "top_k": 100, + "mask_threshold": 0.5, + "predict_all_pixels": true, + "use_mask_score": false, + "threshold": 0.5, + "criterion_deep_supervision": true, + "criterion_eos_coef": 0.1, + "criterion_num_points": 12544, + "weight_dict_loss_dice": 5, + "weight_dict_loss_mask": 5, + "weight_dict_loss_ce": 2, + "matcher_cost_class": 2, + "matcher_cost_mask": 5, + "matcher_cost_dice": 5 + }, + "focoos_model": "bisenetformer-l-ade", + "ref": null, + "status": "TRAINING_COMPLETED", + "description": "BisenetFormer Large model (ADE20K)", + "train_args": null, + "weights_uri": "https://public.focoos.ai/pretrained_models/bisenetformer-l-ade/model_final.pth", + "val_dataset": "ade20k_semseg", + "val_metrics": { + "data_time": 0.0644, + "eta_seconds": 1673.4538, + "iteration": 15999, + "loss_ce": 0.5431, + "loss_dice": 1.0902, + "loss_mask": 0.6968, + "lr": 0.0, + "rank_data_time": 0.0644, + "sem_seg/ACC-animal": 67.8324, + "sem_seg/ACC-arcade machine": 22.4777, + "sem_seg/ACC-armchair": 56.5529, + "sem_seg/ACC-awning, sunshade, sunblind": 27.227, + "sem_seg/ACC-bag": 22.6885, + "sem_seg/ACC-ball": 65.2785, + "sem_seg/ACC-bannister, banister, balustrade, balusters, handrail": 12.9881, + "sem_seg/ACC-bar": 32.9218, + "sem_seg/ACC-barrel, cask": 16.3045, + "sem_seg/ACC-base, pedestal, stand": 30.7385, + "sem_seg/ACC-basket, handbasket": 29.4289, + "sem_seg/ACC-bed": 94.9754, + "sem_seg/ACC-bench": 50.599, + "sem_seg/ACC-bicycle": 72.866, + "sem_seg/ACC-blanket, cover": 24.0579, + "sem_seg/ACC-blind, screen": 59.8316, + "sem_seg/ACC-boat": 81.685, + "sem_seg/ACC-book": 73.7531, + "sem_seg/ACC-bookcase": 37.4401, + "sem_seg/ACC-booth": 65.7302, + "sem_seg/ACC-bottle": 25.2272, + "sem_seg/ACC-box": 31.4893, + "sem_seg/ACC-bridge, span": 82.0893, + "sem_seg/ACC-buffet, counter, sideboard": 57.8992, + "sem_seg/ACC-building": 90.3952, + "sem_seg/ACC-bulletin board": 39.6861, + "sem_seg/ACC-bus": 86.4085, + "sem_seg/ACC-cabinet": 73.2759, + "sem_seg/ACC-canopy": 18.9509, + "sem_seg/ACC-car": 89.3563, + "sem_seg/ACC-case, display case, showcase, vitrine": 57.9627, + "sem_seg/ACC-ceiling": 89.6334, + "sem_seg/ACC-chair": 71.4749, + "sem_seg/ACC-chandelier": 75.6134, + "sem_seg/ACC-chest of drawers, chest, bureau, dresser": 67.2377, + "sem_seg/ACC-clock": 35.9862, + "sem_seg/ACC-clothes": 54.1186, + "sem_seg/ACC-coffee table": 79.2715, + "sem_seg/ACC-column, pillar": 55.076, + "sem_seg/ACC-computer": 62.3605, + "sem_seg/ACC-conveyer belt, conveyor belt, conveyer, conveyor, transporter": 89.236, + "sem_seg/ACC-counter": 42.0217, + "sem_seg/ACC-countertop": 70.3273, + "sem_seg/ACC-cradle": 61.2504, + "sem_seg/ACC-crt screen": 2.3995, + "sem_seg/ACC-curtain": 83.7497, + "sem_seg/ACC-cushion": 63.4362, + "sem_seg/ACC-desk": 61.1527, + "sem_seg/ACC-dirt track": 39.7468, + "sem_seg/ACC-dishwasher": 73.8462, + "sem_seg/ACC-door": 62.0469, + "sem_seg/ACC-earth, ground": 47.2511, + "sem_seg/ACC-escalator, moving staircase, moving stairway": 80.0303, + "sem_seg/ACC-falls": 46.5988, + "sem_seg/ACC-fan": 74.8913, + "sem_seg/ACC-fence": 53.4389, + "sem_seg/ACC-field": 41.4217, + "sem_seg/ACC-fireplace": 87.3472, + "sem_seg/ACC-flag": 31.6665, + "sem_seg/ACC-floor": 87.9016, + "sem_seg/ACC-flower": 47.6333, + "sem_seg/ACC-food, solid food": 49.8544, + "sem_seg/ACC-fountain": 0.164, + "sem_seg/ACC-glass, drinking glass": 20.7863, + "sem_seg/ACC-grandstand, covered stand": 69.3514, + "sem_seg/ACC-grass": 86.3444, + "sem_seg/ACC-hill": 3.0778, + "sem_seg/ACC-hood, exhaust hood": 83.2708, + "sem_seg/ACC-house": 76.1684, + "sem_seg/ACC-hovel, hut, hutch, shack, shanty": 63.1489, + "sem_seg/ACC-kitchen island": 72.8208, + "sem_seg/ACC-lake": 63.3264, + "sem_seg/ACC-lamp": 75.4818, + "sem_seg/ACC-land, ground, soil": 5.1906, + "sem_seg/ACC-light": 70.4908, + "sem_seg/ACC-microwave": 43.0881, + "sem_seg/ACC-minibike, motorbike": 86.7038, + "sem_seg/ACC-mirror": 63.1479, + "sem_seg/ACC-monitor": 13.6432, + "sem_seg/ACC-mountain, mount": 76.6186, + "sem_seg/ACC-ottoman, pouf, pouffe, puff, hassock": 55.6171, + "sem_seg/ACC-oven": 76.6131, + "sem_seg/ACC-painting, picture": 84.1936, + "sem_seg/ACC-palm, palm tree": 68.9341, + "sem_seg/ACC-path": 37.7293, + "sem_seg/ACC-person": 89.4403, + "sem_seg/ACC-pier": 45.7398, + "sem_seg/ACC-pillow": 70.9916, + "sem_seg/ACC-plane": 60.4124, + "sem_seg/ACC-plant": 64.8563, + "sem_seg/ACC-plate": 67.8497, + "sem_seg/ACC-plaything, toy": 45.7922, + "sem_seg/ACC-pole": 40.5167, + "sem_seg/ACC-pool": 78.0721, + "sem_seg/ACC-pool table, billiard table, snooker table": 95.1379, + "sem_seg/ACC-poster, posting, placard, notice, bill, card": 22.9199, + "sem_seg/ACC-pot": 31.7817, + "sem_seg/ACC-radiator": 52.9067, + "sem_seg/ACC-rail": 44.7787, + "sem_seg/ACC-refrigerator, icebox": 82.9822, + "sem_seg/ACC-river": 9.0164, + "sem_seg/ACC-road, route": 88.1344, + "sem_seg/ACC-rock, stone": 57.9449, + "sem_seg/ACC-rug": 70.8222, + "sem_seg/ACC-runway": 90.2757, + "sem_seg/ACC-sand": 45.6829, + "sem_seg/ACC-sconce": 51.6648, + "sem_seg/ACC-screen": 86.4339, + "sem_seg/ACC-screen door, screen": 54.8025, + "sem_seg/ACC-sculpture": 67.4864, + "sem_seg/ACC-sea": 70.9709, + "sem_seg/ACC-seat": 79.6799, + "sem_seg/ACC-shelf": 55.2721, + "sem_seg/ACC-ship": 73.171, + "sem_seg/ACC-shower": 17.9886, + "sem_seg/ACC-sidewalk, pavement": 77.4599, + "sem_seg/ACC-signboard, sign": 50.6553, + "sem_seg/ACC-sink": 78.4458, + "sem_seg/ACC-sky": 96.3511, + "sem_seg/ACC-skyscraper": 43.6313, + "sem_seg/ACC-sofa": 79.4061, + "sem_seg/ACC-stage": 28.7796, + "sem_seg/ACC-stairs": 41.2848, + "sem_seg/ACC-stairway, staircase": 46.7373, + "sem_seg/ACC-step, stair": 20.195, + "sem_seg/ACC-stool": 56.2861, + "sem_seg/ACC-stove": 79.2386, + "sem_seg/ACC-street lamp": 40.88, + "sem_seg/ACC-swivel chair": 56.2099, + "sem_seg/ACC-table": 72.5982, + "sem_seg/ACC-tank, storage tank": 35.8455, + "sem_seg/ACC-tent": 97.3334, + "sem_seg/ACC-toilet, can, commode, crapper, pot, potty, stool, throne": 89.0129, + "sem_seg/ACC-towel": 71.3429, + "sem_seg/ACC-tower": 50.5521, + "sem_seg/ACC-trade name": 27.1781, + "sem_seg/ACC-traffic light": 48.2444, + "sem_seg/ACC-trash can": 45.3939, + "sem_seg/ACC-tray": 15.7707, + "sem_seg/ACC-tree": 86.3837, + "sem_seg/ACC-truck": 50.0152, + "sem_seg/ACC-tub": 84.3858, + "sem_seg/ACC-tv": 79.0012, + "sem_seg/ACC-van": 61.5894, + "sem_seg/ACC-vase": 58.0213, + "sem_seg/ACC-wall": 85.2763, + "sem_seg/ACC-wardrobe, closet, press": 51.2086, + "sem_seg/ACC-washer, automatic washer, washing machine": 70.0124, + "sem_seg/ACC-water": 57.2707, + "sem_seg/ACC-window ": 76.5304, + "sem_seg/IoU-animal": 59.284, + "sem_seg/IoU-arcade machine": 20.9815, + "sem_seg/IoU-armchair": 40.4757, + "sem_seg/IoU-awning, sunshade, sunblind": 19.3275, + "sem_seg/IoU-bag": 16.2127, + "sem_seg/IoU-ball": 43.6117, + "sem_seg/IoU-bannister, banister, balustrade, balusters, handrail": 8.3972, + "sem_seg/IoU-bar": 26.1829, + "sem_seg/IoU-barrel, cask": 1.9664, + "sem_seg/IoU-base, pedestal, stand": 18.4611, + "sem_seg/IoU-basket, handbasket": 18.518, + "sem_seg/IoU-bed": 85.228, + "sem_seg/IoU-bench": 33.7931, + "sem_seg/IoU-bicycle": 55.7144, + "sem_seg/IoU-blanket, cover": 21.4724, + "sem_seg/IoU-blind, screen": 50.2424, + "sem_seg/IoU-boat": 56.4575, + "sem_seg/IoU-book": 44.2526, + "sem_seg/IoU-bookcase": 22.176, + "sem_seg/IoU-booth": 52.551, + "sem_seg/IoU-bottle": 18.2729, + "sem_seg/IoU-box": 20.5596, + "sem_seg/IoU-bridge, span": 69.0172, + "sem_seg/IoU-buffet, counter, sideboard": 47.0857, + "sem_seg/IoU-building": 79.5167, + "sem_seg/IoU-bulletin board": 32.5899, + "sem_seg/IoU-bus": 71.4258, + "sem_seg/IoU-cabinet": 58.9392, + "sem_seg/IoU-canopy": 12.4237, + "sem_seg/IoU-car": 79.7184, + "sem_seg/IoU-case, display case, showcase, vitrine": 41.6357, + "sem_seg/IoU-ceiling": 81.2132, + "sem_seg/IoU-chair": 54.8788, + "sem_seg/IoU-chandelier": 61.4349, + "sem_seg/IoU-chest of drawers, chest, bureau, dresser": 42.7103, + "sem_seg/IoU-clock": 30.8592, + "sem_seg/IoU-clothes": 27.4298, + "sem_seg/IoU-coffee table": 62.1793, + "sem_seg/IoU-column, pillar": 42.4571, + "sem_seg/IoU-computer": 54.0097, + "sem_seg/IoU-conveyer belt, conveyor belt, conveyer, conveyor, transporter": 82.6544, + "sem_seg/IoU-counter": 31.2556, + "sem_seg/IoU-countertop": 53.0762, + "sem_seg/IoU-cradle": 56.6756, + "sem_seg/IoU-crt screen": 0.5111, + "sem_seg/IoU-curtain": 69.9564, + "sem_seg/IoU-cushion": 51.535, + "sem_seg/IoU-desk": 43.025, + "sem_seg/IoU-dirt track": 10.7473, + "sem_seg/IoU-dishwasher": 64.2756, + "sem_seg/IoU-door": 42.5221, + "sem_seg/IoU-earth, ground": 34.6082, + "sem_seg/IoU-escalator, moving staircase, moving stairway": 59.3471, + "sem_seg/IoU-falls": 39.965, + "sem_seg/IoU-fan": 54.2491, + "sem_seg/IoU-fence": 33.7374, + "sem_seg/IoU-field": 27.3649, + "sem_seg/IoU-fireplace": 68.6923, + "sem_seg/IoU-flag": 26.9905, + "sem_seg/IoU-floor": 79.8867, + "sem_seg/IoU-flower": 33.8521, + "sem_seg/IoU-food, solid food": 42.5138, + "sem_seg/IoU-fountain": 0.1636, + "sem_seg/IoU-glass, drinking glass": 17.5079, + "sem_seg/IoU-grandstand, covered stand": 44.0079, + "sem_seg/IoU-grass": 71.5644, + "sem_seg/IoU-hill": 1.454, + "sem_seg/IoU-hood, exhaust hood": 70.4523, + "sem_seg/IoU-house": 50.9592, + "sem_seg/IoU-hovel, hut, hutch, shack, shanty": 55.2253, + "sem_seg/IoU-kitchen island": 38.0829, + "sem_seg/IoU-lake": 60.1825, + "sem_seg/IoU-lamp": 62.4865, + "sem_seg/IoU-land, ground, soil": 3.1466, + "sem_seg/IoU-light": 54.448, + "sem_seg/IoU-microwave": 40.7633, + "sem_seg/IoU-minibike, motorbike": 68.9006, + "sem_seg/IoU-mirror": 54.4623, + "sem_seg/IoU-monitor": 10.2187, + "sem_seg/IoU-mountain, mount": 57.8366, + "sem_seg/IoU-ottoman, pouf, pouffe, puff, hassock": 41.4195, + "sem_seg/IoU-oven": 59.9405, + "sem_seg/IoU-painting, picture": 66.3423, + "sem_seg/IoU-palm, palm tree": 47.7663, + "sem_seg/IoU-path": 23.9741, + "sem_seg/IoU-person": 78.4951, + "sem_seg/IoU-pier": 34.1358, + "sem_seg/IoU-pillow": 55.6646, + "sem_seg/IoU-plane": 52.061, + "sem_seg/IoU-plant": 48.3525, + "sem_seg/IoU-plate": 44.9834, + "sem_seg/IoU-plaything, toy": 25.556, + "sem_seg/IoU-pole": 19.4341, + "sem_seg/IoU-pool": 69.3487, + "sem_seg/IoU-pool table, billiard table, snooker table": 83.9538, + "sem_seg/IoU-poster, posting, placard, notice, bill, card": 13.3479, + "sem_seg/IoU-pot": 26.7454, + "sem_seg/IoU-radiator": 44.8305, + "sem_seg/IoU-rail": 28.138, + "sem_seg/IoU-refrigerator, icebox": 70.9882, + "sem_seg/IoU-river": 4.381, + "sem_seg/IoU-road, route": 80.7483, + "sem_seg/IoU-rock, stone": 36.3489, + "sem_seg/IoU-rug": 62.1959, + "sem_seg/IoU-runway": 70.5139, + "sem_seg/IoU-sand": 41.031, + "sem_seg/IoU-sconce": 38.8911, + "sem_seg/IoU-screen": 70.5819, + "sem_seg/IoU-screen door, screen": 42.7482, + "sem_seg/IoU-sculpture": 42.589, + "sem_seg/IoU-sea": 46.4196, + "sem_seg/IoU-seat": 58.138, + "sem_seg/IoU-shelf": 38.1797, + "sem_seg/IoU-ship": 60.994, + "sem_seg/IoU-shower": 1.945, + "sem_seg/IoU-sidewalk, pavement": 64.2704, + "sem_seg/IoU-signboard, sign": 34.666, + "sem_seg/IoU-sink": 67.4229, + "sem_seg/IoU-sky": 93.4911, + "sem_seg/IoU-skyscraper": 35.9923, + "sem_seg/IoU-sofa": 60.0803, + "sem_seg/IoU-stage": 13.9068, + "sem_seg/IoU-stairs": 29.487, + "sem_seg/IoU-stairway, staircase": 37.9579, + "sem_seg/IoU-step, stair": 15.3962, + "sem_seg/IoU-stool": 43.0004, + "sem_seg/IoU-stove": 68.5066, + "sem_seg/IoU-street lamp": 26.5743, + "sem_seg/IoU-swivel chair": 42.3452, + "sem_seg/IoU-table": 55.8318, + "sem_seg/IoU-tank, storage tank": 34.2209, + "sem_seg/IoU-tent": 84.7484, + "sem_seg/IoU-toilet, can, commode, crapper, pot, potty, stool, throne": 83.9316, + "sem_seg/IoU-towel": 51.0708, + "sem_seg/IoU-tower": 30.2718, + "sem_seg/IoU-trade name": 21.109, + "sem_seg/IoU-traffic light": 27.9047, + "sem_seg/IoU-trash can": 32.6436, + "sem_seg/IoU-tray": 9.2298, + "sem_seg/IoU-tree": 72.8402, + "sem_seg/IoU-truck": 35.5181, + "sem_seg/IoU-tub": 75.4488, + "sem_seg/IoU-tv": 62.2661, + "sem_seg/IoU-van": 46.371, + "sem_seg/IoU-vase": 37.8738, + "sem_seg/IoU-wall": 74.8968, + "sem_seg/IoU-wardrobe, closet, press": 41.7158, + "sem_seg/IoU-washer, automatic washer, washing machine": 65.6808, + "sem_seg/IoU-water": 41.9903, + "sem_seg/IoU-window ": 58.3241, + "sem_seg/fwIoU": 69.694, + "sem_seg/mACC": 58.0273, + "sem_seg/mIoU": 45.0742, + "sem_seg/pACC": 80.832, + "time": 0.42, + "total_loss": 18.8807 + }, + "focoos_version": "0.15.0", + "latency": [], + "updated_at": null +} diff --git a/focoos/model_registry/bisenetformer-m-ade.json b/focoos/model_registry/bisenetformer-m-ade.json new file mode 100644 index 00000000..90ce1912 --- /dev/null +++ b/focoos/model_registry/bisenetformer-m-ade.json @@ -0,0 +1,564 @@ +{ + "name": "bisenetformer-m-ade", + "model_family": "bisenetformer", + "classes": [ + "wall", + "building", + "sky", + "floor", + "tree", + "ceiling", + "road, route", + "bed", + "window ", + "grass", + "cabinet", + "sidewalk, pavement", + "person", + "earth, ground", + "door", + "table", + "mountain, mount", + "plant", + "curtain", + "chair", + "car", + "water", + "painting, picture", + "sofa", + "shelf", + "house", + "sea", + "mirror", + "rug", + "field", + "armchair", + "seat", + "fence", + "desk", + "rock, stone", + "wardrobe, closet, press", + "lamp", + "tub", + "rail", + "cushion", + "base, pedestal, stand", + "box", + "column, pillar", + "signboard, sign", + "chest of drawers, chest, bureau, dresser", + "counter", + "sand", + "sink", + "skyscraper", + "fireplace", + "refrigerator, icebox", + "grandstand, covered stand", + "path", + "stairs", + "runway", + "case, display case, showcase, vitrine", + "pool table, billiard table, snooker table", + "pillow", + "screen door, screen", + "stairway, staircase", + "river", + "bridge, span", + "bookcase", + "blind, screen", + "coffee table", + "toilet, can, commode, crapper, pot, potty, stool, throne", + "flower", + "book", + "hill", + "bench", + "countertop", + "stove", + "palm, palm tree", + "kitchen island", + "computer", + "swivel chair", + "boat", + "bar", + "arcade machine", + "hovel, hut, hutch, shack, shanty", + "bus", + "towel", + "light", + "truck", + "tower", + "chandelier", + "awning, sunshade, sunblind", + "street lamp", + "booth", + "tv", + "plane", + "dirt track", + "clothes", + "pole", + "land, ground, soil", + "bannister, banister, balustrade, balusters, handrail", + "escalator, moving staircase, moving stairway", + "ottoman, pouf, pouffe, puff, hassock", + "bottle", + "buffet, counter, sideboard", + "poster, posting, placard, notice, bill, card", + "stage", + "van", + "ship", + "fountain", + "conveyer belt, conveyor belt, conveyer, conveyor, transporter", + "canopy", + "washer, automatic washer, washing machine", + "plaything, toy", + "pool", + "stool", + "barrel, cask", + "basket, handbasket", + "falls", + "tent", + "bag", + "minibike, motorbike", + "cradle", + "oven", + "ball", + "food, solid food", + "step, stair", + "tank, storage tank", + "trade name", + "microwave", + "pot", + "animal", + "bicycle", + "lake", + "dishwasher", + "screen", + "blanket, cover", + "sculpture", + "hood, exhaust hood", + "sconce", + "vase", + "traffic light", + "tray", + "trash can", + "fan", + "pier", + "crt screen", + "plate", + "monitor", + "bulletin board", + "shower", + "radiator", + "glass, drinking glass", + "clock", + "flag" + ], + "im_size": 640, + "task": "semseg", + "config": { + "num_classes": 150, + "backbone_config": { + "use_pretrained": false, + "backbone_url": null, + "model_type": "stdc", + "in_chans": 3, + "base": 64, + "layers": [ + 4, + 5, + 3 + ], + "out_features": [ + "res2", + "res3", + "res4", + "res5" + ], + "block_num": 4, + "block_type": "cat", + "use_conv_last": false + }, + "num_queries": 100, + "pixel_mean": [ + 123.675, + 116.28, + 103.53 + ], + "pixel_std": [ + 58.395, + 57.12, + 57.375 + ], + "size_divisibility": 0, + "pixel_decoder_out_dim": 96, + "pixel_decoder_feat_dim": 96, + "transformer_predictor_out_dim": 96, + "transformer_predictor_hidden_dim": 256, + "transformer_predictor_dec_layers": 4, + "transformer_predictor_dim_feedforward": 512, + "head_out_dim": 96, + "cls_sigmoid": false, + "postprocessing_type": "semantic", + "top_k": 100, + "mask_threshold": 0.5, + "predict_all_pixels": true, + "use_mask_score": false, + "threshold": 0.5, + "criterion_deep_supervision": true, + "criterion_eos_coef": 0.1, + "criterion_num_points": 12544, + "weight_dict_loss_dice": 5, + "weight_dict_loss_mask": 5, + "weight_dict_loss_ce": 2, + "matcher_cost_class": 2, + "matcher_cost_mask": 5, + "matcher_cost_dice": 5 + }, + "focoos_model": "bisenetformer-m-ade", + "ref": null, + "status": "TRAINING_COMPLETED", + "description": "BisenetFormer Medium model (ADE20K)", + "train_args": null, + "weights_uri": "https://public.focoos.ai/pretrained_models/bisenetformer-m-ade/model_final.pth", + "val_dataset": "ade20k_semseg", + "val_metrics": { + "sem_seg/mIoU": 43.430917376449564, + "sem_seg/fwIoU": 69.105671950822, + "sem_seg/IoU-wall": 73.4927701484079, + "sem_seg/IoU-building": 80.12397109597902, + "sem_seg/IoU-sky": 93.59597560425584, + "sem_seg/IoU-floor": 79.5466081909339, + "sem_seg/IoU-tree": 73.59481371331378, + "sem_seg/IoU-ceiling": 79.2757650678488, + "sem_seg/IoU-road, route": 80.8149655184202, + "sem_seg/IoU-bed": 84.59699251532817, + "sem_seg/IoU-window ": 58.69591357574501, + "sem_seg/IoU-grass": 67.19816739064419, + "sem_seg/IoU-cabinet": 55.662579708582214, + "sem_seg/IoU-sidewalk, pavement": 64.6320590811114, + "sem_seg/IoU-person": 78.02539445342663, + "sem_seg/IoU-earth, ground": 34.87114246338429, + "sem_seg/IoU-door": 42.57233070370406, + "sem_seg/IoU-table": 55.657567583169744, + "sem_seg/IoU-mountain, mount": 56.118323302323816, + "sem_seg/IoU-plant": 53.22519988237882, + "sem_seg/IoU-curtain": 67.93885570399128, + "sem_seg/IoU-chair": 52.66305516617222, + "sem_seg/IoU-car": 80.73767615548904, + "sem_seg/IoU-water": 51.1728282448892, + "sem_seg/IoU-painting, picture": 63.07066982898194, + "sem_seg/IoU-sofa": 57.087509554025985, + "sem_seg/IoU-shelf": 34.369898996565404, + "sem_seg/IoU-house": 45.62877629763861, + "sem_seg/IoU-sea": 56.179680534918276, + "sem_seg/IoU-mirror": 51.445590381140136, + "sem_seg/IoU-rug": 58.78503709652805, + "sem_seg/IoU-field": 37.203472776557064, + "sem_seg/IoU-armchair": 36.46894264868447, + "sem_seg/IoU-seat": 53.0789591051406, + "sem_seg/IoU-fence": 37.24867595135347, + "sem_seg/IoU-desk": 45.76915939877529, + "sem_seg/IoU-rock, stone": 28.0154361425039, + "sem_seg/IoU-wardrobe, closet, press": 43.53935924816774, + "sem_seg/IoU-lamp": 59.350749670586346, + "sem_seg/IoU-tub": 73.34458587895915, + "sem_seg/IoU-rail": 32.95880205366749, + "sem_seg/IoU-cushion": 50.66293723558258, + "sem_seg/IoU-base, pedestal, stand": 31.324423362018937, + "sem_seg/IoU-box": 18.734746334332687, + "sem_seg/IoU-column, pillar": 38.66174958334811, + "sem_seg/IoU-signboard, sign": 34.30455074716889, + "sem_seg/IoU-chest of drawers, chest, bureau, dresser": 35.626063987498455, + "sem_seg/IoU-counter": 34.250298632446665, + "sem_seg/IoU-sand": 36.65812442615901, + "sem_seg/IoU-sink": 69.40994682774169, + "sem_seg/IoU-skyscraper": 47.442921653456274, + "sem_seg/IoU-fireplace": 75.58083465928162, + "sem_seg/IoU-refrigerator, icebox": 67.47843615445301, + "sem_seg/IoU-grandstand, covered stand": 37.44872314941276, + "sem_seg/IoU-path": 25.184062185363786, + "sem_seg/IoU-stairs": 17.526853807356844, + "sem_seg/IoU-runway": 65.86674986793176, + "sem_seg/IoU-case, display case, showcase, vitrine": 46.51615333110144, + "sem_seg/IoU-pool table, billiard table, snooker table": 90.22726995143016, + "sem_seg/IoU-pillow": 52.93867085383067, + "sem_seg/IoU-screen door, screen": 50.530788785530966, + "sem_seg/IoU-stairway, staircase": 28.105241822833072, + "sem_seg/IoU-river": 15.044450773950107, + "sem_seg/IoU-bridge, span": 57.57562918396746, + "sem_seg/IoU-bookcase": 26.420849187654767, + "sem_seg/IoU-blind, screen": 35.62789290328912, + "sem_seg/IoU-coffee table": 60.71283602002511, + "sem_seg/IoU-toilet, can, commode, crapper, pot, potty, stool, throne": 84.14620461963908, + "sem_seg/IoU-flower": 37.956704433565854, + "sem_seg/IoU-book": 46.589711727816415, + "sem_seg/IoU-hill": 8.586624275698298, + "sem_seg/IoU-bench": 37.62457733390647, + "sem_seg/IoU-countertop": 46.049281664750104, + "sem_seg/IoU-stove": 59.84416003087004, + "sem_seg/IoU-palm, palm tree": 45.70194788075779, + "sem_seg/IoU-kitchen island": 37.21894351153295, + "sem_seg/IoU-computer": 52.380485104947304, + "sem_seg/IoU-swivel chair": 34.31994561330708, + "sem_seg/IoU-boat": 57.36769280297458, + "sem_seg/IoU-bar": 17.3602273802888, + "sem_seg/IoU-arcade machine": 58.90023870305823, + "sem_seg/IoU-hovel, hut, hutch, shack, shanty": 28.154640681513904, + "sem_seg/IoU-bus": 83.90606881383637, + "sem_seg/IoU-towel": 50.790379398307486, + "sem_seg/IoU-light": 54.158730397729414, + "sem_seg/IoU-truck": 27.09218090531363, + "sem_seg/IoU-tower": 37.69072977290149, + "sem_seg/IoU-chandelier": 62.741699626909096, + "sem_seg/IoU-awning, sunshade, sunblind": 21.157277732992664, + "sem_seg/IoU-street lamp": 24.88393886344886, + "sem_seg/IoU-booth": 27.20916440365827, + "sem_seg/IoU-tv": 55.09473294112197, + "sem_seg/IoU-plane": 46.57900601738355, + "sem_seg/IoU-dirt track": 0.10020369275248045, + "sem_seg/IoU-clothes": 32.728646428640616, + "sem_seg/IoU-pole": 14.881248401258476, + "sem_seg/IoU-land, ground, soil": 3.134324106844028, + "sem_seg/IoU-bannister, banister, balustrade, balusters, handrail": 9.043076430352528, + "sem_seg/IoU-escalator, moving staircase, moving stairway": 40.1838100382216, + "sem_seg/IoU-ottoman, pouf, pouffe, puff, hassock": 41.08857393859765, + "sem_seg/IoU-bottle": 16.785858695586576, + "sem_seg/IoU-buffet, counter, sideboard": 31.344208972982162, + "sem_seg/IoU-poster, posting, placard, notice, bill, card": 13.599533663654912, + "sem_seg/IoU-stage": 7.150501312320266, + "sem_seg/IoU-van": 44.019652845072336, + "sem_seg/IoU-ship": 78.97036474164135, + "sem_seg/IoU-fountain": 1.7140551526261256, + "sem_seg/IoU-conveyer belt, conveyor belt, conveyer, conveyor, transporter": 48.11099134284927, + "sem_seg/IoU-canopy": 20.54527771497797, + "sem_seg/IoU-washer, automatic washer, washing machine": 60.34788333896828, + "sem_seg/IoU-plaything, toy": 22.97481356620939, + "sem_seg/IoU-pool": 63.46751198687417, + "sem_seg/IoU-stool": 44.488624851659964, + "sem_seg/IoU-barrel, cask": 7.763701390288132, + "sem_seg/IoU-basket, handbasket": 19.144067027792655, + "sem_seg/IoU-falls": 54.050758670520224, + "sem_seg/IoU-tent": 84.61773288083985, + "sem_seg/IoU-bag": 13.35700041259799, + "sem_seg/IoU-minibike, motorbike": 55.13423270995852, + "sem_seg/IoU-cradle": 71.36781624746881, + "sem_seg/IoU-oven": 29.408412502614933, + "sem_seg/IoU-ball": 40.42481927038267, + "sem_seg/IoU-food, solid food": 55.33859347551549, + "sem_seg/IoU-step, stair": 9.79963292731278, + "sem_seg/IoU-tank, storage tank": 30.540832149237364, + "sem_seg/IoU-trade name": 28.006308621085836, + "sem_seg/IoU-microwave": 36.05153377850499, + "sem_seg/IoU-pot": 36.74510713017401, + "sem_seg/IoU-animal": 53.00767519168896, + "sem_seg/IoU-bicycle": 56.410496845320225, + "sem_seg/IoU-lake": 0.0, + "sem_seg/IoU-dishwasher": 65.08238695935282, + "sem_seg/IoU-screen": 48.41823022826803, + "sem_seg/IoU-blanket, cover": 11.54815418539649, + "sem_seg/IoU-sculpture": 36.75213675213676, + "sem_seg/IoU-hood, exhaust hood": 64.78834770172023, + "sem_seg/IoU-sconce": 36.42987095409351, + "sem_seg/IoU-vase": 36.30188642758381, + "sem_seg/IoU-traffic light": 26.774441277155166, + "sem_seg/IoU-tray": 13.945473344337826, + "sem_seg/IoU-trash can": 33.20863792040818, + "sem_seg/IoU-fan": 55.00063663225926, + "sem_seg/IoU-pier": 29.49969838636706, + "sem_seg/IoU-crt screen": 4.3669486750969355, + "sem_seg/IoU-plate": 35.9551923789966, + "sem_seg/IoU-monitor": 11.1822996438523, + "sem_seg/IoU-bulletin board": 47.98852782259097, + "sem_seg/IoU-shower": 0.9780691920324327, + "sem_seg/IoU-radiator": 44.42665678790204, + "sem_seg/IoU-glass, drinking glass": 13.841748802378723, + "sem_seg/IoU-clock": 26.290821931489067, + "sem_seg/IoU-flag": 24.884797111533913, + "sem_seg/mACC": 57.0120037851652, + "sem_seg/pACC": 80.3414690077635, + "sem_seg/ACC-wall": 83.63493687903146, + "sem_seg/ACC-building": 90.50320177598566, + "sem_seg/ACC-sky": 96.38605374000299, + "sem_seg/ACC-floor": 87.6762295522717, + "sem_seg/ACC-tree": 84.98028835989221, + "sem_seg/ACC-ceiling": 87.8146592868969, + "sem_seg/ACC-road, route": 88.1536837136885, + "sem_seg/ACC-bed": 93.41899325761678, + "sem_seg/ACC-window ": 77.26955494892661, + "sem_seg/ACC-grass": 79.68654101001296, + "sem_seg/ACC-cabinet": 73.34956467616968, + "sem_seg/ACC-sidewalk, pavement": 81.67553871665658, + "sem_seg/ACC-person": 88.12301196343907, + "sem_seg/ACC-earth, ground": 51.789451525493256, + "sem_seg/ACC-door": 61.579378776237505, + "sem_seg/ACC-table": 73.738500134035, + "sem_seg/ACC-mountain, mount": 80.31202669411371, + "sem_seg/ACC-plant": 66.42900935200258, + "sem_seg/ACC-curtain": 83.16360911905649, + "sem_seg/ACC-chair": 67.78806247207918, + "sem_seg/ACC-car": 88.63663224057788, + "sem_seg/ACC-water": 65.83155766725042, + "sem_seg/ACC-painting, picture": 82.91580060404901, + "sem_seg/ACC-sofa": 74.85540668657237, + "sem_seg/ACC-shelf": 54.089288496327114, + "sem_seg/ACC-house": 63.580015434915424, + "sem_seg/ACC-sea": 80.83930086234959, + "sem_seg/ACC-mirror": 61.11628054239745, + "sem_seg/ACC-rug": 69.33139513912435, + "sem_seg/ACC-field": 64.87633874948723, + "sem_seg/ACC-armchair": 55.74435727729289, + "sem_seg/ACC-seat": 77.68665362456323, + "sem_seg/ACC-fence": 54.43784459728301, + "sem_seg/ACC-desk": 68.13408307738408, + "sem_seg/ACC-rock, stone": 43.75767160000515, + "sem_seg/ACC-wardrobe, closet, press": 71.2993990195037, + "sem_seg/ACC-lamp": 72.07473602847391, + "sem_seg/ACC-tub": 88.31094681683767, + "sem_seg/ACC-rail": 52.03953937348111, + "sem_seg/ACC-cushion": 63.285161708595986, + "sem_seg/ACC-base, pedestal, stand": 62.477044792207046, + "sem_seg/ACC-box": 32.276807602952836, + "sem_seg/ACC-column, pillar": 52.650970890236884, + "sem_seg/ACC-signboard, sign": 48.46383785360163, + "sem_seg/ACC-chest of drawers, chest, bureau, dresser": 52.53976882315649, + "sem_seg/ACC-counter": 42.912267645735845, + "sem_seg/ACC-sand": 44.64176405858022, + "sem_seg/ACC-sink": 77.41703492232278, + "sem_seg/ACC-skyscraper": 62.61852662290299, + "sem_seg/ACC-fireplace": 88.88825490499751, + "sem_seg/ACC-refrigerator, icebox": 75.91307194978538, + "sem_seg/ACC-grandstand, covered stand": 64.66454455400404, + "sem_seg/ACC-path": 37.07388839523508, + "sem_seg/ACC-stairs": 22.276263173999837, + "sem_seg/ACC-runway": 76.89233770170449, + "sem_seg/ACC-case, display case, showcase, vitrine": 69.69730221184143, + "sem_seg/ACC-pool table, billiard table, snooker table": 94.21754391931628, + "sem_seg/ACC-pillow": 66.40468260711506, + "sem_seg/ACC-screen door, screen": 68.59963977430252, + "sem_seg/ACC-stairway, staircase": 44.62543663108674, + "sem_seg/ACC-river": 25.50307753771346, + "sem_seg/ACC-bridge, span": 70.64346252952359, + "sem_seg/ACC-bookcase": 37.477587371006265, + "sem_seg/ACC-blind, screen": 40.934320834537175, + "sem_seg/ACC-coffee table": 79.18917144123586, + "sem_seg/ACC-toilet, can, commode, crapper, pot, potty, stool, throne": 87.94181151026078, + "sem_seg/ACC-flower": 54.37251555972291, + "sem_seg/ACC-book": 68.96322377313977, + "sem_seg/ACC-hill": 10.643240712022525, + "sem_seg/ACC-bench": 54.253031308439404, + "sem_seg/ACC-countertop": 59.66841841570458, + "sem_seg/ACC-stove": 74.49008595465911, + "sem_seg/ACC-palm, palm tree": 73.95427201347863, + "sem_seg/ACC-kitchen island": 64.80539812019234, + "sem_seg/ACC-computer": 62.228451296426066, + "sem_seg/ACC-swivel chair": 49.96799214329236, + "sem_seg/ACC-boat": 78.39400066426212, + "sem_seg/ACC-bar": 21.296672601537907, + "sem_seg/ACC-arcade machine": 66.21784839081755, + "sem_seg/ACC-hovel, hut, hutch, shack, shanty": 46.755848040089525, + "sem_seg/ACC-bus": 92.62617411780266, + "sem_seg/ACC-towel": 67.6686294131451, + "sem_seg/ACC-light": 66.6954072940931, + "sem_seg/ACC-truck": 46.6344905447361, + "sem_seg/ACC-tower": 49.84722210422879, + "sem_seg/ACC-chandelier": 78.0756478894381, + "sem_seg/ACC-awning, sunshade, sunblind": 28.44034338232323, + "sem_seg/ACC-street lamp": 44.114754265446315, + "sem_seg/ACC-booth": 45.97913348032028, + "sem_seg/ACC-tv": 65.23990543112764, + "sem_seg/ACC-plane": 64.28818606320304, + "sem_seg/ACC-dirt track": 0.11735508570768964, + "sem_seg/ACC-clothes": 51.339852795487836, + "sem_seg/ACC-pole": 31.63848982431343, + "sem_seg/ACC-land, ground, soil": 4.5574089605400765, + "sem_seg/ACC-bannister, banister, balustrade, balusters, handrail": 12.911338657259975, + "sem_seg/ACC-escalator, moving staircase, moving stairway": 62.692539022421215, + "sem_seg/ACC-ottoman, pouf, pouffe, puff, hassock": 57.6334212994264, + "sem_seg/ACC-bottle": 21.975886098952323, + "sem_seg/ACC-buffet, counter, sideboard": 41.19728768465653, + "sem_seg/ACC-poster, posting, placard, notice, bill, card": 21.48973151352044, + "sem_seg/ACC-stage": 22.219607503142825, + "sem_seg/ACC-van": 62.06298148139271, + "sem_seg/ACC-ship": 80.09363169280692, + "sem_seg/ACC-fountain": 1.8317660492463461, + "sem_seg/ACC-conveyer belt, conveyor belt, conveyer, conveyor, transporter": 85.65803911443878, + "sem_seg/ACC-canopy": 28.382312211621752, + "sem_seg/ACC-washer, automatic washer, washing machine": 72.02318252093988, + "sem_seg/ACC-plaything, toy": 43.98899230614068, + "sem_seg/ACC-pool": 69.14306538942108, + "sem_seg/ACC-stool": 59.60513049210843, + "sem_seg/ACC-barrel, cask": 70.14449880532484, + "sem_seg/ACC-basket, handbasket": 24.08481247077622, + "sem_seg/ACC-falls": 61.66726776237827, + "sem_seg/ACC-tent": 97.38950692207307, + "sem_seg/ACC-bag": 18.245436879524227, + "sem_seg/ACC-minibike, motorbike": 80.70198508583044, + "sem_seg/ACC-cradle": 81.21926934170952, + "sem_seg/ACC-oven": 51.45785474465453, + "sem_seg/ACC-ball": 52.06965150280427, + "sem_seg/ACC-food, solid food": 68.17976604991736, + "sem_seg/ACC-step, stair": 14.163355609443249, + "sem_seg/ACC-tank, storage tank": 35.9250705671029, + "sem_seg/ACC-trade name": 39.21338652689707, + "sem_seg/ACC-microwave": 38.60638480496136, + "sem_seg/ACC-pot": 45.09539836079466, + "sem_seg/ACC-animal": 56.65492534025711, + "sem_seg/ACC-bicycle": 73.43447554276746, + "sem_seg/ACC-lake": 0.0, + "sem_seg/ACC-dishwasher": 73.80861489552109, + "sem_seg/ACC-screen": 57.53939402931925, + "sem_seg/ACC-blanket, cover": 16.042886615124928, + "sem_seg/ACC-sculpture": 60.93174413464529, + "sem_seg/ACC-hood, exhaust hood": 69.97999238315927, + "sem_seg/ACC-sconce": 48.532503621534175, + "sem_seg/ACC-vase": 56.91018091145419, + "sem_seg/ACC-traffic light": 49.896907216494846, + "sem_seg/ACC-tray": 21.47456041116744, + "sem_seg/ACC-trash can": 46.95645938820311, + "sem_seg/ACC-fan": 76.03734187971934, + "sem_seg/ACC-pier": 43.55231482125711, + "sem_seg/ACC-crt screen": 15.775476522348878, + "sem_seg/ACC-plate": 48.89625847866455, + "sem_seg/ACC-monitor": 13.259027814244954, + "sem_seg/ACC-bulletin board": 58.3775162453974, + "sem_seg/ACC-shower": 7.815223707147008, + "sem_seg/ACC-radiator": 52.15345189096839, + "sem_seg/ACC-glass, drinking glass": 16.62544146241452, + "sem_seg/ACC-clock": 34.946899298312154, + "sem_seg/ACC-flag": 30.5972872878263 + }, + "focoos_version": "0.15.0", + "latency": [ + { + "fps": 185, + "engine": "onnx.TensorrtExecutionProvider", + "min": 5.095, + "max": 8.2, + "mean": 5.388, + "std": 0.492, + "im_size": 640, + "device": "Tesla T4" + }, + { + "fps": 95, + "engine": "torchscript", + "min": 8.233, + "max": 11.674, + "mean": 10.456, + "std": 0.813, + "im_size": 640, + "device": "Tesla T4" + }, + { + "fps": 69, + "engine": "onnx.CUDAExecutionProvider", + "min": 14.125, + "max": 14.535, + "mean": 14.319, + "std": 0.093, + "im_size": 640, + "device": "Tesla T4" + } + ], + "updated_at": null +} diff --git a/focoos/model_registry/bisenetformer-s-ade.json b/focoos/model_registry/bisenetformer-s-ade.json new file mode 100644 index 00000000..76c1e8f3 --- /dev/null +++ b/focoos/model_registry/bisenetformer-s-ade.json @@ -0,0 +1,543 @@ +{ + "name": "bisenetformer-s-ade", + "model_family": "bisenetformer", + "classes": [ + "wall", + "building", + "sky", + "floor", + "tree", + "ceiling", + "road, route", + "bed", + "window ", + "grass", + "cabinet", + "sidewalk, pavement", + "person", + "earth, ground", + "door", + "table", + "mountain, mount", + "plant", + "curtain", + "chair", + "car", + "water", + "painting, picture", + "sofa", + "shelf", + "house", + "sea", + "mirror", + "rug", + "field", + "armchair", + "seat", + "fence", + "desk", + "rock, stone", + "wardrobe, closet, press", + "lamp", + "tub", + "rail", + "cushion", + "base, pedestal, stand", + "box", + "column, pillar", + "signboard, sign", + "chest of drawers, chest, bureau, dresser", + "counter", + "sand", + "sink", + "skyscraper", + "fireplace", + "refrigerator, icebox", + "grandstand, covered stand", + "path", + "stairs", + "runway", + "case, display case, showcase, vitrine", + "pool table, billiard table, snooker table", + "pillow", + "screen door, screen", + "stairway, staircase", + "river", + "bridge, span", + "bookcase", + "blind, screen", + "coffee table", + "toilet, can, commode, crapper, pot, potty, stool, throne", + "flower", + "book", + "hill", + "bench", + "countertop", + "stove", + "palm, palm tree", + "kitchen island", + "computer", + "swivel chair", + "boat", + "bar", + "arcade machine", + "hovel, hut, hutch, shack, shanty", + "bus", + "towel", + "light", + "truck", + "tower", + "chandelier", + "awning, sunshade, sunblind", + "street lamp", + "booth", + "tv", + "plane", + "dirt track", + "clothes", + "pole", + "land, ground, soil", + "bannister, banister, balustrade, balusters, handrail", + "escalator, moving staircase, moving stairway", + "ottoman, pouf, pouffe, puff, hassock", + "bottle", + "buffet, counter, sideboard", + "poster, posting, placard, notice, bill, card", + "stage", + "van", + "ship", + "fountain", + "conveyer belt, conveyor belt, conveyer, conveyor, transporter", + "canopy", + "washer, automatic washer, washing machine", + "plaything, toy", + "pool", + "stool", + "barrel, cask", + "basket, handbasket", + "falls", + "tent", + "bag", + "minibike, motorbike", + "cradle", + "oven", + "ball", + "food, solid food", + "step, stair", + "tank, storage tank", + "trade name", + "microwave", + "pot", + "animal", + "bicycle", + "lake", + "dishwasher", + "screen", + "blanket, cover", + "sculpture", + "hood, exhaust hood", + "sconce", + "vase", + "traffic light", + "tray", + "trash can", + "fan", + "pier", + "crt screen", + "plate", + "monitor", + "bulletin board", + "shower", + "radiator", + "glass, drinking glass", + "clock", + "flag" + ], + "im_size": 640, + "task": "semseg", + "config": { + "num_classes": 150, + "backbone_config": { + "use_pretrained": false, + "backbone_url": null, + "model_type": "stdc", + "in_chans": 3, + "base": 64, + "layers": [ + 2, + 2, + 2 + ], + "out_features": [ + "res2", + "res3", + "res4", + "res5" + ], + "block_num": 4, + "block_type": "cat", + "use_conv_last": false + }, + "num_queries": 100, + "pixel_mean": [ + 123.675, + 116.28, + 103.53 + ], + "pixel_std": [ + 58.395, + 57.12, + 57.375 + ], + "size_divisibility": 0, + "pixel_decoder_out_dim": 128, + "pixel_decoder_feat_dim": 128, + "transformer_predictor_out_dim": 128, + "transformer_predictor_hidden_dim": 256, + "transformer_predictor_dec_layers": 6, + "transformer_predictor_dim_feedforward": 1024, + "head_out_dim": 128, + "cls_sigmoid": false, + "postprocessing_type": "semantic", + "top_k": 100, + "mask_threshold": 0.5, + "predict_all_pixels": true, + "use_mask_score": false, + "threshold": 0.5, + "criterion_deep_supervision": true, + "criterion_eos_coef": 0.1, + "criterion_num_points": 12544, + "weight_dict_loss_dice": 5, + "weight_dict_loss_mask": 5, + "weight_dict_loss_ce": 2, + "matcher_cost_class": 2, + "matcher_cost_mask": 5, + "matcher_cost_dice": 5 + }, + "focoos_model": "bisenetformer-s-ade", + "ref": null, + "status": "TRAINING_COMPLETED", + "description": "BisenetFormer small model (ADE20K)", + "train_args": null, + "weights_uri": "https://public.focoos.ai/pretrained_models/bisenetformer-s-ade/model_final.pth", + "val_dataset": "ade20k_semseg", + "val_metrics": { + "data_time": 0.0501, + "eta_seconds": 5322.009, + "iteration": 5999, + "loss_ce": 0.6278, + "loss_dice": 1.2257, + "loss_mask": 0.7712, + "lr": 0.0, + "rank_data_time": 0.0501, + "sem_seg/ACC-animal": 59.4651, + "sem_seg/ACC-arcade machine": 21.4633, + "sem_seg/ACC-armchair": 53.4835, + "sem_seg/ACC-awning, sunshade, sunblind": 24.8364, + "sem_seg/ACC-bag": 15.9764, + "sem_seg/ACC-ball": 17.2547, + "sem_seg/ACC-bannister, banister, balustrade, balusters, handrail": 17.7433, + "sem_seg/ACC-bar": 33.6085, + "sem_seg/ACC-barrel, cask": 51.7806, + "sem_seg/ACC-base, pedestal, stand": 38.483, + "sem_seg/ACC-basket, handbasket": 28.6977, + "sem_seg/ACC-bed": 93.8828, + "sem_seg/ACC-bench": 46.211, + "sem_seg/ACC-bicycle": 74.9551, + "sem_seg/ACC-blanket, cover": 20.4288, + "sem_seg/ACC-blind, screen": 43.1849, + "sem_seg/ACC-boat": 82.1637, + "sem_seg/ACC-book": 68.0669, + "sem_seg/ACC-bookcase": 46.709, + "sem_seg/ACC-booth": 65.5421, + "sem_seg/ACC-bottle": 57.9895, + "sem_seg/ACC-box": 30.9757, + "sem_seg/ACC-bridge, span": 77.3652, + "sem_seg/ACC-buffet, counter, sideboard": 51.701, + "sem_seg/ACC-building": 90.8873, + "sem_seg/ACC-bulletin board": 39.5677, + "sem_seg/ACC-bus": 94.2674, + "sem_seg/ACC-cabinet": 70.1976, + "sem_seg/ACC-canopy": 28.4681, + "sem_seg/ACC-car": 88.2694, + "sem_seg/ACC-case, display case, showcase, vitrine": 61.8502, + "sem_seg/ACC-ceiling": 86.981, + "sem_seg/ACC-chair": 68.2999, + "sem_seg/ACC-chandelier": 73.765, + "sem_seg/ACC-chest of drawers, chest, bureau, dresser": 65.0819, + "sem_seg/ACC-clock": 33.2173, + "sem_seg/ACC-clothes": 48.5131, + "sem_seg/ACC-coffee table": 78.3607, + "sem_seg/ACC-column, pillar": 54.9071, + "sem_seg/ACC-computer": 63.2703, + "sem_seg/ACC-conveyer belt, conveyor belt, conveyer, conveyor, transporter": 88.8793, + "sem_seg/ACC-counter": 32.7307, + "sem_seg/ACC-countertop": 61.1979, + "sem_seg/ACC-cradle": 82.1452, + "sem_seg/ACC-crt screen": 22.3814, + "sem_seg/ACC-curtain": 79.6478, + "sem_seg/ACC-cushion": 59.9649, + "sem_seg/ACC-desk": 62.7636, + "sem_seg/ACC-dirt track": 31.128, + "sem_seg/ACC-dishwasher": 67.8766, + "sem_seg/ACC-door": 56.9642, + "sem_seg/ACC-earth, ground": 44.3439, + "sem_seg/ACC-escalator, moving staircase, moving stairway": 78.81, + "sem_seg/ACC-falls": 81.4903, + "sem_seg/ACC-fan": 70.6849, + "sem_seg/ACC-fence": 49.6679, + "sem_seg/ACC-field": 47.3892, + "sem_seg/ACC-fireplace": 73.8184, + "sem_seg/ACC-flag": 33.4368, + "sem_seg/ACC-floor": 85.9605, + "sem_seg/ACC-flower": 45.1602, + "sem_seg/ACC-food, solid food": 68.4388, + "sem_seg/ACC-fountain": 10.1218, + "sem_seg/ACC-glass, drinking glass": 16.0897, + "sem_seg/ACC-grandstand, covered stand": 66.9777, + "sem_seg/ACC-grass": 82.5244, + "sem_seg/ACC-hill": 12.3114, + "sem_seg/ACC-hood, exhaust hood": 65.8009, + "sem_seg/ACC-house": 56.8494, + "sem_seg/ACC-hovel, hut, hutch, shack, shanty": 37.0229, + "sem_seg/ACC-kitchen island": 72.9591, + "sem_seg/ACC-lake": 62.9917, + "sem_seg/ACC-lamp": 74.0535, + "sem_seg/ACC-land, ground, soil": 8.8798, + "sem_seg/ACC-light": 65.2948, + "sem_seg/ACC-microwave": 36.4314, + "sem_seg/ACC-minibike, motorbike": 56.8875, + "sem_seg/ACC-mirror": 67.6929, + "sem_seg/ACC-monitor": 18.681, + "sem_seg/ACC-mountain, mount": 70.1326, + "sem_seg/ACC-ottoman, pouf, pouffe, puff, hassock": 46.3872, + "sem_seg/ACC-oven": 64.9111, + "sem_seg/ACC-painting, picture": 82.8493, + "sem_seg/ACC-palm, palm tree": 63.7095, + "sem_seg/ACC-path": 33.3722, + "sem_seg/ACC-person": 86.9764, + "sem_seg/ACC-pier": 17.4944, + "sem_seg/ACC-pillow": 66.475, + "sem_seg/ACC-plane": 64.3074, + "sem_seg/ACC-plant": 61.9582, + "sem_seg/ACC-plate": 44.5031, + "sem_seg/ACC-plaything, toy": 31.2271, + "sem_seg/ACC-pole": 37.4507, + "sem_seg/ACC-pool": 47.3479, + "sem_seg/ACC-pool table, billiard table, snooker table": 93.0167, + "sem_seg/ACC-poster, posting, placard, notice, bill, card": 27.5899, + "sem_seg/ACC-pot": 43.6033, + "sem_seg/ACC-radiator": 47.2408, + "sem_seg/ACC-rail": 44.5119, + "sem_seg/ACC-refrigerator, icebox": 88.0233, + "sem_seg/ACC-river": 18.7395, + "sem_seg/ACC-road, route": 88.3694, + "sem_seg/ACC-rock, stone": 52.6246, + "sem_seg/ACC-rug": 72.748, + "sem_seg/ACC-runway": 85.6781, + "sem_seg/ACC-sand": 46.3865, + "sem_seg/ACC-sconce": 53.9262, + "sem_seg/ACC-screen": 84.298, + "sem_seg/ACC-screen door, screen": 63.9579, + "sem_seg/ACC-sculpture": 62.352, + "sem_seg/ACC-sea": 79.3346, + "sem_seg/ACC-seat": 78.0974, + "sem_seg/ACC-shelf": 62.7044, + "sem_seg/ACC-ship": 74.709, + "sem_seg/ACC-shower": 18.5551, + "sem_seg/ACC-sidewalk, pavement": 80.9542, + "sem_seg/ACC-signboard, sign": 47.648, + "sem_seg/ACC-sink": 77.8285, + "sem_seg/ACC-sky": 96.2925, + "sem_seg/ACC-skyscraper": 80.1256, + "sem_seg/ACC-sofa": 78.8736, + "sem_seg/ACC-stage": 18.3151, + "sem_seg/ACC-stairs": 35.6968, + "sem_seg/ACC-stairway, staircase": 50.8282, + "sem_seg/ACC-step, stair": 13.6393, + "sem_seg/ACC-stool": 59.1567, + "sem_seg/ACC-stove": 74.128, + "sem_seg/ACC-street lamp": 41.9746, + "sem_seg/ACC-swivel chair": 57.7463, + "sem_seg/ACC-table": 69.3639, + "sem_seg/ACC-tank, storage tank": 43.4213, + "sem_seg/ACC-tent": 98.6567, + "sem_seg/ACC-toilet, can, commode, crapper, pot, potty, stool, throne": 87.273, + "sem_seg/ACC-towel": 65.2788, + "sem_seg/ACC-tower": 21.7871, + "sem_seg/ACC-trade name": 27.5066, + "sem_seg/ACC-traffic light": 36.9012, + "sem_seg/ACC-trash can": 46.0177, + "sem_seg/ACC-tray": 21.6631, + "sem_seg/ACC-tree": 87.3226, + "sem_seg/ACC-truck": 42.6915, + "sem_seg/ACC-tub": 81.6142, + "sem_seg/ACC-tv": 72.0498, + "sem_seg/ACC-van": 57.4916, + "sem_seg/ACC-vase": 53.3511, + "sem_seg/ACC-wall": 85.0262, + "sem_seg/ACC-wardrobe, closet, press": 58.3992, + "sem_seg/ACC-washer, automatic washer, washing machine": 71.3808, + "sem_seg/ACC-water": 58.1261, + "sem_seg/ACC-window ": 74.566, + "sem_seg/IoU-animal": 54.8811, + "sem_seg/IoU-arcade machine": 19.3903, + "sem_seg/IoU-armchair": 35.7742, + "sem_seg/IoU-awning, sunshade, sunblind": 18.7121, + "sem_seg/IoU-bag": 11.3607, + "sem_seg/IoU-ball": 14.5184, + "sem_seg/IoU-bannister, banister, balustrade, balusters, handrail": 10.2539, + "sem_seg/IoU-bar": 26.6001, + "sem_seg/IoU-barrel, cask": 33.6513, + "sem_seg/IoU-base, pedestal, stand": 23.9065, + "sem_seg/IoU-basket, handbasket": 18.2833, + "sem_seg/IoU-bed": 85.2883, + "sem_seg/IoU-bench": 31.2459, + "sem_seg/IoU-bicycle": 39.2919, + "sem_seg/IoU-blanket, cover": 17.0509, + "sem_seg/IoU-blind, screen": 37.0417, + "sem_seg/IoU-boat": 53.5943, + "sem_seg/IoU-book": 44.9529, + "sem_seg/IoU-bookcase": 29.231, + "sem_seg/IoU-booth": 56.4898, + "sem_seg/IoU-bottle": 32.5003, + "sem_seg/IoU-box": 19.7964, + "sem_seg/IoU-bridge, span": 63.4212, + "sem_seg/IoU-buffet, counter, sideboard": 48.9777, + "sem_seg/IoU-building": 80.1136, + "sem_seg/IoU-bulletin board": 35.2734, + "sem_seg/IoU-bus": 80.9362, + "sem_seg/IoU-cabinet": 55.5328, + "sem_seg/IoU-canopy": 18.2453, + "sem_seg/IoU-car": 79.0814, + "sem_seg/IoU-case, display case, showcase, vitrine": 46.8982, + "sem_seg/IoU-ceiling": 79.4272, + "sem_seg/IoU-chair": 51.4873, + "sem_seg/IoU-chandelier": 60.4021, + "sem_seg/IoU-chest of drawers, chest, bureau, dresser": 33.7311, + "sem_seg/IoU-clock": 25.6457, + "sem_seg/IoU-clothes": 24.9389, + "sem_seg/IoU-coffee table": 57.7826, + "sem_seg/IoU-column, pillar": 40.5312, + "sem_seg/IoU-computer": 54.3031, + "sem_seg/IoU-conveyer belt, conveyor belt, conveyer, conveyor, transporter": 61.4307, + "sem_seg/IoU-counter": 21.0091, + "sem_seg/IoU-countertop": 49.4856, + "sem_seg/IoU-cradle": 67.1717, + "sem_seg/IoU-crt screen": 10.8201, + "sem_seg/IoU-curtain": 65.2658, + "sem_seg/IoU-cushion": 48.0915, + "sem_seg/IoU-desk": 39.9544, + "sem_seg/IoU-dirt track": 10.1405, + "sem_seg/IoU-dishwasher": 59.6283, + "sem_seg/IoU-door": 40.5338, + "sem_seg/IoU-earth, ground": 32.5205, + "sem_seg/IoU-escalator, moving staircase, moving stairway": 46.7108, + "sem_seg/IoU-falls": 57.279, + "sem_seg/IoU-fan": 53.8773, + "sem_seg/IoU-fence": 32.8748, + "sem_seg/IoU-field": 29.5774, + "sem_seg/IoU-fireplace": 63.7948, + "sem_seg/IoU-flag": 28.5042, + "sem_seg/IoU-floor": 78.8182, + "sem_seg/IoU-flower": 31.7102, + "sem_seg/IoU-food, solid food": 56.8953, + "sem_seg/IoU-fountain": 9.0042, + "sem_seg/IoU-glass, drinking glass": 13.2531, + "sem_seg/IoU-grandstand, covered stand": 46.7537, + "sem_seg/IoU-grass": 65.3315, + "sem_seg/IoU-hill": 8.0879, + "sem_seg/IoU-hood, exhaust hood": 56.0338, + "sem_seg/IoU-house": 40.8892, + "sem_seg/IoU-hovel, hut, hutch, shack, shanty": 22.2008, + "sem_seg/IoU-kitchen island": 35.8952, + "sem_seg/IoU-lake": 41.2283, + "sem_seg/IoU-lamp": 59.2924, + "sem_seg/IoU-land, ground, soil": 4.401, + "sem_seg/IoU-light": 51.6561, + "sem_seg/IoU-microwave": 34.6015, + "sem_seg/IoU-minibike, motorbike": 47.3331, + "sem_seg/IoU-mirror": 51.4131, + "sem_seg/IoU-monitor": 16.0734, + "sem_seg/IoU-mountain, mount": 51.3077, + "sem_seg/IoU-ottoman, pouf, pouffe, puff, hassock": 31.4618, + "sem_seg/IoU-oven": 46.8948, + "sem_seg/IoU-painting, picture": 61.3698, + "sem_seg/IoU-palm, palm tree": 49.8255, + "sem_seg/IoU-path": 20.824, + "sem_seg/IoU-person": 77.5217, + "sem_seg/IoU-pier": 15.7544, + "sem_seg/IoU-pillow": 53.6763, + "sem_seg/IoU-plane": 38.8305, + "sem_seg/IoU-plant": 48.1367, + "sem_seg/IoU-plate": 34.7804, + "sem_seg/IoU-plaything, toy": 15.3901, + "sem_seg/IoU-pole": 20.6331, + "sem_seg/IoU-pool": 30.9664, + "sem_seg/IoU-pool table, billiard table, snooker table": 88.9589, + "sem_seg/IoU-poster, posting, placard, notice, bill, card": 16.359, + "sem_seg/IoU-pot": 36.2998, + "sem_seg/IoU-radiator": 38.5636, + "sem_seg/IoU-rail": 28.1515, + "sem_seg/IoU-refrigerator, icebox": 70.6937, + "sem_seg/IoU-river": 11.3514, + "sem_seg/IoU-road, route": 81.4634, + "sem_seg/IoU-rock, stone": 35.9798, + "sem_seg/IoU-rug": 59.5627, + "sem_seg/IoU-runway": 67.5071, + "sem_seg/IoU-sand": 31.1366, + "sem_seg/IoU-sconce": 37.3541, + "sem_seg/IoU-screen": 63.3706, + "sem_seg/IoU-screen door, screen": 39.7789, + "sem_seg/IoU-sculpture": 42.6556, + "sem_seg/IoU-sea": 52.8994, + "sem_seg/IoU-seat": 55.4184, + "sem_seg/IoU-shelf": 42.2553, + "sem_seg/IoU-ship": 61.9431, + "sem_seg/IoU-shower": 2.0809, + "sem_seg/IoU-sidewalk, pavement": 62.023, + "sem_seg/IoU-signboard, sign": 31.4473, + "sem_seg/IoU-sink": 64.9508, + "sem_seg/IoU-sky": 93.4588, + "sem_seg/IoU-skyscraper": 60.3735, + "sem_seg/IoU-sofa": 59.5768, + "sem_seg/IoU-stage": 11.4936, + "sem_seg/IoU-stairs": 26.2443, + "sem_seg/IoU-stairway, staircase": 29.362, + "sem_seg/IoU-step, stair": 12.3009, + "sem_seg/IoU-stool": 43.4297, + "sem_seg/IoU-stove": 65.3644, + "sem_seg/IoU-street lamp": 27.2149, + "sem_seg/IoU-swivel chair": 39.5318, + "sem_seg/IoU-table": 53.2465, + "sem_seg/IoU-tank, storage tank": 40.6984, + "sem_seg/IoU-tent": 86.2697, + "sem_seg/IoU-toilet, can, commode, crapper, pot, potty, stool, throne": 82.1525, + "sem_seg/IoU-towel": 50.4661, + "sem_seg/IoU-tower": 15.8699, + "sem_seg/IoU-trade name": 22.5816, + "sem_seg/IoU-traffic light": 22.3724, + "sem_seg/IoU-trash can": 32.1307, + "sem_seg/IoU-tray": 11.0681, + "sem_seg/IoU-tree": 72.8509, + "sem_seg/IoU-truck": 25.0448, + "sem_seg/IoU-tub": 74.5703, + "sem_seg/IoU-tv": 60.7701, + "sem_seg/IoU-van": 40.14, + "sem_seg/IoU-vase": 32.0744, + "sem_seg/IoU-wall": 73.1829, + "sem_seg/IoU-wardrobe, closet, press": 43.8235, + "sem_seg/IoU-washer, automatic washer, washing machine": 65.8158, + "sem_seg/IoU-water": 45.8088, + "sem_seg/IoU-window ": 57.5142, + "sem_seg/fwIoU": 68.509, + "sem_seg/mACC": 56.5532, + "sem_seg/mIoU": 42.9098, + "sem_seg/pACC": 79.9501, + "time": 0.3752, + "total_loss": 21.628 + }, + "focoos_version": "0.15.0", + "latency": [], + "updated_at": null +} diff --git a/focoos/model_registry/fai-detr-l-coco.json b/focoos/model_registry/fai-detr-l-coco.json new file mode 100644 index 00000000..5bb7595a --- /dev/null +++ b/focoos/model_registry/fai-detr-l-coco.json @@ -0,0 +1,278 @@ +{ + "name": "fai-detr-l-coco", + "model_family": "fai_detr", + "classes": [ + "person", + "bicycle", + "car", + "motorcycle", + "airplane", + "bus", + "train", + "truck", + "boat", + "traffic light", + "fire hydrant", + "stop sign", + "parking meter", + "bench", + "bird", + "cat", + "dog", + "horse", + "sheep", + "cow", + "elephant", + "bear", + "zebra", + "giraffe", + "backpack", + "umbrella", + "handbag", + "tie", + "suitcase", + "frisbee", + "skis", + "snowboard", + "sports ball", + "kite", + "baseball bat", + "baseball glove", + "skateboard", + "surfboard", + "tennis racket", + "bottle", + "wine glass", + "cup", + "fork", + "knife", + "spoon", + "bowl", + "banana", + "apple", + "sandwich", + "orange", + "broccoli", + "carrot", + "hot dog", + "pizza", + "donut", + "cake", + "chair", + "couch", + "potted plant", + "bed", + "dining table", + "toilet", + "tv", + "laptop", + "mouse", + "remote", + "keyboard", + "cell phone", + "microwave", + "oven", + "toaster", + "sink", + "refrigerator", + "book", + "clock", + "vase", + "scissors", + "teddy bear", + "hair drier", + "toothbrush" + ], + "im_size": 640, + "task": "detection", + "config": { + "num_classes": 80, + "backbone_config": { + "use_pretrained": false, + "backbone_url": null, + "model_type": "resnet", + "in_chans": 3, + "depth": 50, + "variant": "d", + "freeze_at": -1, + "num_stages": 4, + "freeze_norm": false, + "act": "relu", + "pretrained": false + }, + "num_queries": 300, + "resolution": 640, + "pixel_mean": [ + 123.675, + 116.28, + 103.53 + ], + "pixel_std": [ + 58.395, + 57.12, + 57.375 + ], + "size_divisibility": 0, + "pixel_decoder_out_dim": 256, + "pixel_decoder_feat_dim": 256, + "pixel_decoder_num_encoder_layers": 1, + "pixel_decoder_expansion": 1.0, + "pixel_decoder_dim_feedforward": 1024, + "transformer_predictor_out_dim": 256, + "transformer_predictor_hidden_dim": 256, + "transformer_predictor_dec_layers": 6, + "transformer_predictor_dim_feedforward": 1024, + "head_out_dim": 256, + "pixel_decoder_dropout": 0.0, + "pixel_decoder_nhead": 8, + "transformer_predictor_nhead": 8, + "threshold": 0.5, + "criterion_deep_supervision": true, + "criterion_eos_coef": 0.1, + "criterion_losses": [ + "vfl", + "boxes" + ], + "criterion_num_points": 0, + "criterion_focal_alpha": 0.75, + "criterion_focal_gamma": 2.0, + "weight_dict_loss_vfl": 1, + "weight_dict_loss_bbox": 5, + "weight_dict_loss_giou": 2, + "matcher_cost_class": 2, + "matcher_cost_bbox": 5, + "matcher_cost_giou": 2, + "matcher_use_focal_loss": true, + "matcher_alpha": 0.25, + "matcher_gamma": 2.0 + }, + "focoos_model": "fai-detr-l-coco", + "ref": null, + "status": "TRAINING_COMPLETED", + "description": "RTDETR Large model (COCO)", + "train_args": null, + "weights_uri": "https://public.focoos.ai/pretrained_models/fai-detr-l-coco/model_final.pth", + "val_dataset": "coco_2017_det", + "val_metrics": { + "bbox/AP": 53.056417759304985, + "bbox/AP50": 70.90948912606201, + "bbox/AP75": 57.35402776438957, + "bbox/APs": 35.552855979233335, + "bbox/APm": 57.40337140316705, + "bbox/APl": 70.24329695398886, + "bbox/AP-person": 63.19540347437229, + "bbox/AP-bicycle": 40.60536228546631, + "bbox/AP-car": 52.37137035179139, + "bbox/AP-motorcycle": 54.98134198148118, + "bbox/AP-airplane": 76.16498294981814, + "bbox/AP-bus": 74.91466700043637, + "bbox/AP-train": 74.96239036955086, + "bbox/AP-truck": 47.86730448644042, + "bbox/AP-boat": 36.63433088163561, + "bbox/AP-traffic light": 32.66332992920803, + "bbox/AP-fire hydrant": 75.52416483014126, + "bbox/AP-stop sign": 71.21341401373937, + "bbox/AP-parking meter": 54.59761438538464, + "bbox/AP-bench": 34.88272948072085, + "bbox/AP-bird": 46.61911838467906, + "bbox/AP-cat": 79.6797723698286, + "bbox/AP-dog": 75.52019535417955, + "bbox/AP-horse": 69.67868164069931, + "bbox/AP-sheep": 62.93550725756516, + "bbox/AP-cow": 68.76504604584319, + "bbox/AP-elephant": 74.03560347540426, + "bbox/AP-bear": 83.06842179847959, + "bbox/AP-zebra": 78.23818596422814, + "bbox/AP-giraffe": 76.92978655040412, + "bbox/AP-backpack": 25.087258285688435, + "bbox/AP-umbrella": 53.78453135323754, + "bbox/AP-handbag": 24.290254505317403, + "bbox/AP-tie": 44.84396475288874, + "bbox/AP-suitcase": 52.6236788514319, + "bbox/AP-frisbee": 75.3181160951581, + "bbox/AP-skis": 37.229958108441714, + "bbox/AP-snowboard": 50.77272747007975, + "bbox/AP-sports ball": 53.93165959999288, + "bbox/AP-kite": 54.810939036776205, + "bbox/AP-baseball bat": 53.03138900615072, + "bbox/AP-baseball glove": 45.17419844746425, + "bbox/AP-skateboard": 63.730449909233975, + "bbox/AP-surfboard": 50.25106368683214, + "bbox/AP-tennis racket": 61.08131314520174, + "bbox/AP-bottle": 48.79299780402071, + "bbox/AP-wine glass": 44.04915908223806, + "bbox/AP-cup": 53.486547912877626, + "bbox/AP-fork": 51.31397064375852, + "bbox/AP-knife": 34.11648635358094, + "bbox/AP-spoon": 33.47215373760887, + "bbox/AP-bowl": 52.12034250938331, + "bbox/AP-banana": 33.00669715933448, + "bbox/AP-apple": 27.15669519421343, + "bbox/AP-sandwich": 48.169054536715834, + "bbox/AP-orange": 37.84997843214523, + "bbox/AP-broccoli": 28.840805809919406, + "bbox/AP-carrot": 28.187450937012944, + "bbox/AP-hot dog": 50.18003165655384, + "bbox/AP-pizza": 62.49645962338424, + "bbox/AP-donut": 62.17216616829773, + "bbox/AP-cake": 47.55480895915476, + "bbox/AP-chair": 41.1565946469719, + "bbox/AP-couch": 57.31055333293209, + "bbox/AP-potted plant": 35.89556101184211, + "bbox/AP-bed": 58.24762756910633, + "bbox/AP-dining table": 39.344970323752484, + "bbox/AP-toilet": 72.80307136690166, + "bbox/AP-tv": 65.87207550758897, + "bbox/AP-laptop": 73.12357612787504, + "bbox/AP-mouse": 67.09584509863812, + "bbox/AP-remote": 48.1909039332051, + "bbox/AP-keyboard": 62.981860786924074, + "bbox/AP-cell phone": 45.93813508511771, + "bbox/AP-microwave": 64.53981735587784, + "bbox/AP-oven": 44.88252720465844, + "bbox/AP-toaster": 50.33117007168168, + "bbox/AP-sink": 45.69304852670981, + "bbox/AP-refrigerator": 69.34087685149767, + "bbox/AP-book": 22.34250300067005, + "bbox/AP-clock": 59.257937022062755, + "bbox/AP-vase": 45.6232526798821, + "bbox/AP-scissors": 42.85092900958757, + "bbox/AP-teddy bear": 59.42368856135565, + "bbox/AP-hair drier": 35.29086141315212, + "bbox/AP-toothbrush": 42.00000022081457 + }, + "focoos_version": "0.15.0", + "latency": [ + { + "fps": 92, + "engine": "onnx.TensorrtExecutionProvider", + "min": 10.374, + "max": 11.24, + "mean": 10.853, + "std": 0.134, + "im_size": 640, + "device": "Tesla T4" + }, + { + "fps": 26, + "engine": "torchscript", + "min": 36.778, + "max": 40.55, + "mean": 37.835, + "std": 0.616, + "im_size": 640, + "device": "Tesla T4" + }, + { + "fps": 22, + "engine": "onnx.CUDAExecutionProvider", + "min": 44.102, + "max": 46.549, + "mean": 45.051, + "std": 0.55, + "im_size": 640, + "device": "Tesla T4" + } + ], + "updated_at": null +} diff --git a/focoos/model_registry/fai-detr-l-obj365.json b/focoos/model_registry/fai-detr-l-obj365.json new file mode 100644 index 00000000..1ddb7221 --- /dev/null +++ b/focoos/model_registry/fai-detr-l-obj365.json @@ -0,0 +1,476 @@ +{ + "name": "fai-detr-l-obj365", + "model_family": "fai_detr", + "classes": [ + "Person", + "Sneakers", + "Chair", + "Other Shoes", + "Hat", + "Car", + "Lamp", + "Glasses", + "Bottle", + "Desk", + "Cup", + "Street Lights", + "Cabinet/shelf", + "Handbag/Satchel", + "Bracelet", + "Plate", + "Picture/Frame", + "Helmet", + "Book", + "Gloves", + "Storage box", + "Boat", + "Leather Shoes", + "Flower", + "Bench", + "Potted Plant", + "Bowl/Basin", + "Flag", + "Pillow", + "Boots", + "Vase", + "Microphone", + "Necklace", + "Ring", + "SUV", + "Wine Glass", + "Belt", + "Moniter/TV", + "Backpack", + "Umbrella", + "Traffic Light", + "Speaker", + "Watch", + "Tie", + "Trash bin Can", + "Slippers", + "Bicycle", + "Stool", + "Barrel/bucket", + "Van", + "Couch", + "Sandals", + "Bakset", + "Drum", + "Pen/Pencil", + "Bus", + "Wild Bird", + "High Heels", + "Motorcycle", + "Guitar", + "Carpet", + "Cell Phone", + "Bread", + "Camera", + "Canned", + "Truck", + "Traffic cone", + "Cymbal", + "Lifesaver", + "Towel", + "Stuffed Toy", + "Candle", + "Sailboat", + "Laptop", + "Awning", + "Bed", + "Faucet", + "Tent", + "Horse", + "Mirror", + "Power outlet", + "Sink", + "Apple", + "Air Conditioner", + "Knife", + "Hockey Stick", + "Paddle", + "Pickup Truck", + "Fork", + "Traffic Sign", + "Ballon", + "Tripod", + "Dog", + "Spoon", + "Clock", + "Pot", + "Cow", + "Cake", + "Dinning Table", + "Sheep", + "Hanger", + "Blackboard/Whiteboard", + "Napkin", + "Other Fish", + "Orange/Tangerine", + "Toiletry", + "Keyboard", + "Tomato", + "Lantern", + "Machinery Vehicle", + "Fan", + "Green Vegetables", + "Banana", + "Baseball Glove", + "Airplane", + "Mouse", + "Train", + "Pumpkin", + "Soccer", + "Skiboard", + "Luggage", + "Nightstand", + "Tea pot", + "Telephone", + "Trolley", + "Head Phone", + "Sports Car", + "Stop Sign", + "Dessert", + "Scooter", + "Stroller", + "Crane", + "Remote", + "Refrigerator", + "Oven", + "Lemon", + "Duck", + "Baseball Bat", + "Surveillance Camera", + "Cat", + "Jug", + "Broccoli", + "Piano", + "Pizza", + "Elephant", + "Skateboard", + "Surfboard", + "Gun", + "Skating and Skiing shoes", + "Gas stove", + "Donut", + "Bow Tie", + "Carrot", + "Toilet", + "Kite", + "Strawberry", + "Other Balls", + "Shovel", + "Pepper", + "Computer Box", + "Toilet Paper", + "Cleaning Products", + "Chopsticks", + "Microwave", + "Pigeon", + "Baseball", + "Cutting/chopping Board", + "Coffee Table", + "Side Table", + "Scissors", + "Marker", + "Pie", + "Ladder", + "Snowboard", + "Cookies", + "Radiator", + "Fire Hydrant", + "Basketball", + "Zebra", + "Grape", + "Giraffe", + "Potato", + "Sausage", + "Tricycle", + "Violin", + "Egg", + "Fire Extinguisher", + "Candy", + "Fire Truck", + "Billards", + "Converter", + "Bathtub", + "Wheelchair", + "Golf Club", + "Briefcase", + "Cucumber", + "Cigar/Cigarette ", + "Paint Brush", + "Pear", + "Heavy Truck", + "Hamburger", + "Extractor", + "Extention Cord", + "Tong", + "Tennis Racket", + "Folder", + "American Football", + "earphone", + "Mask", + "Kettle", + "Tennis", + "Ship", + "Swing", + "Coffee Machine", + "Slide", + "Carriage", + "Onion", + "Green beans", + "Projector", + "Frisbee", + "Washing Machine/Drying Machine", + "Chicken", + "Printer", + "Watermelon", + "Saxophone", + "Tissue", + "Toothbrush", + "Ice cream", + "Hotair ballon", + "Cello", + "French Fries", + "Scale", + "Trophy", + "Cabbage", + "Hot dog", + "Blender", + "Peach", + "Rice", + "Wallet/Purse", + "Volleyball", + "Deer", + "Goose", + "Tape", + "Tablet", + "Cosmetics", + "Trumpet", + "Pineapple", + "Golf Ball", + "Ambulance", + "Parking meter", + "Mango", + "Key", + "Hurdle", + "Fishing Rod", + "Medal", + "Flute", + "Brush", + "Penguin", + "Megaphone", + "Corn", + "Lettuce", + "Garlic", + "Swan", + "Helicopter", + "Green Onion", + "Sandwich", + "Nuts", + "Speed Limit Sign", + "Induction Cooker", + "Broom", + "Trombone", + "Plum", + "Rickshaw", + "Goldfish", + "Kiwi fruit", + "Router/modem", + "Poker Card", + "Toaster", + "Shrimp", + "Sushi", + "Cheese", + "Notepaper", + "Cherry", + "Pliers", + "CD", + "Pasta", + "Hammer", + "Cue", + "Avocado", + "Hamimelon", + "Flask", + "Mushroon", + "Screwdriver", + "Soap", + "Recorder", + "Bear", + "Eggplant", + "Board Eraser", + "Coconut", + "Tape Measur/ Ruler", + "Pig", + "Showerhead", + "Globe", + "Chips", + "Steak", + "Crosswalk Sign", + "Stapler", + "Campel", + "Formula 1 ", + "Pomegranate", + "Dishwasher", + "Crab", + "Hoverboard", + "Meat ball", + "Rice Cooker", + "Tuba", + "Calculator", + "Papaya", + "Antelope", + "Parrot", + "Seal", + "Buttefly", + "Dumbbell", + "Donkey", + "Lion", + "Urinal", + "Dolphin", + "Electric Drill", + "Hair Dryer", + "Egg tart", + "Jellyfish", + "Treadmill", + "Lighter", + "Grapefruit", + "Game board", + "Mop", + "Radish", + "Baozi", + "Target", + "French", + "Spring Rolls", + "Monkey", + "Rabbit", + "Pencil Case", + "Yak", + "Red Cabbage", + "Binoculars", + "Asparagus", + "Barbell", + "Scallop", + "Noddles", + "Comb", + "Dumpling", + "Oyster", + "Table Teniis paddle", + "Cosmetics Brush/Eyeliner Pencil", + "Chainsaw", + "Eraser", + "Lobster", + "Durian", + "Okra", + "Lipstick", + "Cosmetics Mirror", + "Curling", + "Table Tennis " + ], + "im_size": 640, + "task": "detection", + "config": { + "num_classes": 365, + "backbone_config": { + "use_pretrained": false, + "backbone_url": null, + "model_type": "resnet", + "in_chans": 3, + "depth": 50, + "variant": "d", + "freeze_at": -1, + "num_stages": 4, + "freeze_norm": false, + "act": "relu", + "pretrained": false + }, + "num_queries": 300, + "resolution": 640, + "pixel_mean": [ + 123.675, + 116.28, + 103.53 + ], + "pixel_std": [ + 58.395, + 57.12, + 57.375 + ], + "size_divisibility": 0, + "pixel_decoder_out_dim": 256, + "pixel_decoder_feat_dim": 256, + "pixel_decoder_num_encoder_layers": 1, + "pixel_decoder_expansion": 1.0, + "pixel_decoder_dim_feedforward": 1024, + "transformer_predictor_out_dim": 256, + "transformer_predictor_hidden_dim": 256, + "transformer_predictor_dec_layers": 6, + "transformer_predictor_dim_feedforward": 1024, + "head_out_dim": 256, + "pixel_decoder_dropout": 0.0, + "pixel_decoder_nhead": 8, + "transformer_predictor_nhead": 8, + "threshold": 0.5, + "criterion_deep_supervision": true, + "criterion_eos_coef": 0.1, + "criterion_losses": [ + "vfl", + "boxes" + ], + "criterion_num_points": 0, + "criterion_focal_alpha": 0.75, + "criterion_focal_gamma": 2.0, + "weight_dict_loss_vfl": 1, + "weight_dict_loss_bbox": 5, + "weight_dict_loss_giou": 2, + "matcher_cost_class": 2, + "matcher_cost_bbox": 5, + "matcher_cost_giou": 2, + "matcher_use_focal_loss": true, + "matcher_alpha": 0.25, + "matcher_gamma": 2.0 + }, + "focoos_model": "fai-detr-l-obj365", + "ref": null, + "status": "TRAINING_COMPLETED", + "description": "RTDETR Large model (Object365)", + "train_args": null, + "weights_uri": "https://public.focoos.ai/pretrained_models/fai-detr-l-obj365/model_final.pth", + "val_dataset": "object365", + "val_metrics": null, + "focoos_version": "0.15.0", + "latency": [ + { + "fps": 91, + "engine": "onnx.TensorrtExecutionProvider", + "min": 10.623, + "max": 11.182, + "mean": 10.965, + "std": 0.099, + "im_size": 640, + "device": "Tesla T4" + }, + { + "fps": 26, + "engine": "torchscript", + "min": 37.296, + "max": 41.131, + "mean": 38.235, + "std": 0.699, + "im_size": 640, + "device": "Tesla T4" + }, + { + "fps": 21, + "engine": "onnx.CUDAExecutionProvider", + "min": 44.645, + "max": 47.315, + "mean": 45.669, + "std": 0.502, + "im_size": 640, + "device": "Tesla T4" + } + ], + "updated_at": null +} diff --git a/focoos/model_registry/fai-detr-m-coco.json b/focoos/model_registry/fai-detr-m-coco.json new file mode 100644 index 00000000..547c43d1 --- /dev/null +++ b/focoos/model_registry/fai-detr-m-coco.json @@ -0,0 +1,286 @@ +{ + "name": "fai-detr-m-coco", + "model_family": "fai_detr", + "classes": [ + "person", + "bicycle", + "car", + "motorcycle", + "airplane", + "bus", + "train", + "truck", + "boat", + "traffic light", + "fire hydrant", + "stop sign", + "parking meter", + "bench", + "bird", + "cat", + "dog", + "horse", + "sheep", + "cow", + "elephant", + "bear", + "zebra", + "giraffe", + "backpack", + "umbrella", + "handbag", + "tie", + "suitcase", + "frisbee", + "skis", + "snowboard", + "sports ball", + "kite", + "baseball bat", + "baseball glove", + "skateboard", + "surfboard", + "tennis racket", + "bottle", + "wine glass", + "cup", + "fork", + "knife", + "spoon", + "bowl", + "banana", + "apple", + "sandwich", + "orange", + "broccoli", + "carrot", + "hot dog", + "pizza", + "donut", + "cake", + "chair", + "couch", + "potted plant", + "bed", + "dining table", + "toilet", + "tv", + "laptop", + "mouse", + "remote", + "keyboard", + "cell phone", + "microwave", + "oven", + "toaster", + "sink", + "refrigerator", + "book", + "clock", + "vase", + "scissors", + "teddy bear", + "hair drier", + "toothbrush" + ], + "im_size": 640, + "task": "detection", + "config": { + "num_classes": 80, + "backbone_config": { + "use_pretrained": false, + "backbone_url": null, + "model_type": "stdc", + "in_chans": 3, + "base": 64, + "layers": [ + 4, + 5, + 3 + ], + "out_features": [ + "res2", + "res3", + "res4", + "res5" + ], + "block_num": 4, + "block_type": "cat", + "use_conv_last": false + }, + "num_queries": 300, + "resolution": 640, + "pixel_mean": [ + 123.675, + 116.28, + 103.53 + ], + "pixel_std": [ + 58.395, + 57.12, + 57.375 + ], + "size_divisibility": 0, + "pixel_decoder_out_dim": 128, + "pixel_decoder_feat_dim": 128, + "pixel_decoder_num_encoder_layers": 0, + "pixel_decoder_expansion": 1.0, + "pixel_decoder_dim_feedforward": 1024, + "transformer_predictor_out_dim": 128, + "transformer_predictor_hidden_dim": 256, + "transformer_predictor_dec_layers": 3, + "transformer_predictor_dim_feedforward": 1024, + "head_out_dim": 128, + "pixel_decoder_dropout": 0.0, + "pixel_decoder_nhead": 8, + "transformer_predictor_nhead": 8, + "threshold": 0.5, + "criterion_deep_supervision": true, + "criterion_eos_coef": 0.1, + "criterion_losses": [ + "vfl", + "boxes" + ], + "criterion_num_points": 0, + "criterion_focal_alpha": 0.75, + "criterion_focal_gamma": 2.0, + "weight_dict_loss_vfl": 1, + "weight_dict_loss_bbox": 5, + "weight_dict_loss_giou": 2, + "matcher_cost_class": 2, + "matcher_cost_bbox": 5, + "matcher_cost_giou": 2, + "matcher_use_focal_loss": true, + "matcher_alpha": 0.25, + "matcher_gamma": 2.0 + }, + "focoos_model": "fai-detr-m-coco", + "ref": null, + "status": "TRAINING_COMPLETED", + "description": "RTDETR Medium model (COCO)", + "train_args": null, + "weights_uri": "https://public.focoos.ai/pretrained_models/fai-detr-m-coco/model_final.pth", + "val_dataset": "coco_2017_det", + "val_metrics": { + "bbox/AP": 44.67705555113968, + "bbox/AP50": 61.61373451952256, + "bbox/AP75": 47.861477112028936, + "bbox/APs": 25.564530901698703, + "bbox/APm": 48.41683105300949, + "bbox/APl": 61.911735314721106, + "bbox/AP-person": 56.343431946848234, + "bbox/AP-bicycle": 32.780502152212584, + "bbox/AP-car": 44.20159541372662, + "bbox/AP-motorcycle": 47.88026858491337, + "bbox/AP-airplane": 72.55838592989555, + "bbox/AP-bus": 70.44254318841648, + "bbox/AP-train": 69.13275286274481, + "bbox/AP-truck": 39.55826680488379, + "bbox/AP-boat": 27.848771718272573, + "bbox/AP-traffic light": 26.575071887178776, + "bbox/AP-fire hydrant": 68.77006748799718, + "bbox/AP-stop sign": 64.38324007761653, + "bbox/AP-parking meter": 45.51714570494032, + "bbox/AP-bench": 25.974863180058506, + "bbox/AP-bird": 37.38826330872534, + "bbox/AP-cat": 73.34353436035921, + "bbox/AP-dog": 68.55112445539827, + "bbox/AP-horse": 59.45223276034842, + "bbox/AP-sheep": 56.273872237379564, + "bbox/AP-cow": 59.181060766872605, + "bbox/AP-elephant": 67.85339641790078, + "bbox/AP-bear": 77.93733813590504, + "bbox/AP-zebra": 71.38347074989363, + "bbox/AP-giraffe": 72.19670084222432, + "bbox/AP-backpack": 16.637798843741166, + "bbox/AP-umbrella": 43.39477285353671, + "bbox/AP-handbag": 16.598538078771103, + "bbox/AP-tie": 36.458939501741625, + "bbox/AP-suitcase": 44.36138308523266, + "bbox/AP-frisbee": 68.03200391844642, + "bbox/AP-skis": 27.33014866571027, + "bbox/AP-snowboard": 34.9628039139027, + "bbox/AP-sports ball": 46.528673164658635, + "bbox/AP-kite": 44.80013208918985, + "bbox/AP-baseball bat": 29.208136027700675, + "bbox/AP-baseball glove": 38.43071319643022, + "bbox/AP-skateboard": 56.149762847846304, + "bbox/AP-surfboard": 43.302231374859026, + "bbox/AP-tennis racket": 49.18828274032414, + "bbox/AP-bottle": 37.7660854976408, + "bbox/AP-wine glass": 35.63488925053308, + "bbox/AP-cup": 43.10677955080388, + "bbox/AP-fork": 38.95491065526809, + "bbox/AP-knife": 22.56595054093037, + "bbox/AP-spoon": 20.356070632323217, + "bbox/AP-bowl": 43.46133254581892, + "bbox/AP-banana": 27.04446595816097, + "bbox/AP-apple": 22.23860156287028, + "bbox/AP-sandwich": 38.759757560494506, + "bbox/AP-orange": 33.40287441858537, + "bbox/AP-broccoli": 24.769287154911034, + "bbox/AP-carrot": 23.780921106915624, + "bbox/AP-hot dog": 38.440503753163036, + "bbox/AP-pizza": 57.45199828300909, + "bbox/AP-donut": 50.63842508573877, + "bbox/AP-cake": 38.3536489874511, + "bbox/AP-chair": 30.927889724391278, + "bbox/AP-couch": 49.97012862335106, + "bbox/AP-potted plant": 28.936152060451352, + "bbox/AP-bed": 51.41909758487877, + "bbox/AP-dining table": 32.78767521034203, + "bbox/AP-toilet": 67.13637930997773, + "bbox/AP-tv": 59.40056652461345, + "bbox/AP-laptop": 62.77657538559278, + "bbox/AP-mouse": 64.6881746530067, + "bbox/AP-remote": 34.32807105251235, + "bbox/AP-keyboard": 55.842619161475284, + "bbox/AP-cell phone": 38.220669505105505, + "bbox/AP-microwave": 61.406298346136765, + "bbox/AP-oven": 41.77315956041754, + "bbox/AP-toaster": 48.35074746498584, + "bbox/AP-sink": 39.373221917873224, + "bbox/AP-refrigerator": 59.50654355161424, + "bbox/AP-book": 15.538101838945503, + "bbox/AP-clock": 48.96855507583324, + "bbox/AP-vase": 39.27130556564371, + "bbox/AP-scissors": 30.334468305804524, + "bbox/AP-teddy bear": 50.50150389581418, + "bbox/AP-hair drier": 4.847323408146282, + "bbox/AP-toothbrush": 30.220492542838574 + }, + "focoos_version": "0.15.0", + "latency": [ + { + "fps": 180, + "engine": "onnx.TensorrtExecutionProvider", + "min": 5.395, + "max": 5.614, + "mean": 5.541, + "std": 0.044, + "im_size": 640, + "device": "Tesla T4" + }, + { + "fps": 66, + "engine": "torchscript", + "min": 12.091, + "max": 17.234, + "mean": 14.978, + "std": 0.694, + "im_size": 640, + "device": "Tesla T4" + }, + { + "fps": 57, + "engine": "onnx.CUDAExecutionProvider", + "min": 17.04, + "max": 18.226, + "mean": 17.409, + "std": 0.254, + "im_size": 640, + "device": "Tesla T4" + } + ], + "updated_at": null +} diff --git a/focoos/model_registry/fai-mf-l-ade.json b/focoos/model_registry/fai-mf-l-ade.json new file mode 100644 index 00000000..fda60c61 --- /dev/null +++ b/focoos/model_registry/fai-mf-l-ade.json @@ -0,0 +1,561 @@ +{ + "name": "fai-mf-l-ade", + "model_family": "fai_mf", + "classes": [ + "wall", + "building", + "sky", + "floor", + "tree", + "ceiling", + "road, route", + "bed", + "window ", + "grass", + "cabinet", + "sidewalk, pavement", + "person", + "earth, ground", + "door", + "table", + "mountain, mount", + "plant", + "curtain", + "chair", + "car", + "water", + "painting, picture", + "sofa", + "shelf", + "house", + "sea", + "mirror", + "rug", + "field", + "armchair", + "seat", + "fence", + "desk", + "rock, stone", + "wardrobe, closet, press", + "lamp", + "tub", + "rail", + "cushion", + "base, pedestal, stand", + "box", + "column, pillar", + "signboard, sign", + "chest of drawers, chest, bureau, dresser", + "counter", + "sand", + "sink", + "skyscraper", + "fireplace", + "refrigerator, icebox", + "grandstand, covered stand", + "path", + "stairs", + "runway", + "case, display case, showcase, vitrine", + "pool table, billiard table, snooker table", + "pillow", + "screen door, screen", + "stairway, staircase", + "river", + "bridge, span", + "bookcase", + "blind, screen", + "coffee table", + "toilet, can, commode, crapper, pot, potty, stool, throne", + "flower", + "book", + "hill", + "bench", + "countertop", + "stove", + "palm, palm tree", + "kitchen island", + "computer", + "swivel chair", + "boat", + "bar", + "arcade machine", + "hovel, hut, hutch, shack, shanty", + "bus", + "towel", + "light", + "truck", + "tower", + "chandelier", + "awning, sunshade, sunblind", + "street lamp", + "booth", + "tv", + "plane", + "dirt track", + "clothes", + "pole", + "land, ground, soil", + "bannister, banister, balustrade, balusters, handrail", + "escalator, moving staircase, moving stairway", + "ottoman, pouf, pouffe, puff, hassock", + "bottle", + "buffet, counter, sideboard", + "poster, posting, placard, notice, bill, card", + "stage", + "van", + "ship", + "fountain", + "conveyer belt, conveyor belt, conveyer, conveyor, transporter", + "canopy", + "washer, automatic washer, washing machine", + "plaything, toy", + "pool", + "stool", + "barrel, cask", + "basket, handbasket", + "falls", + "tent", + "bag", + "minibike, motorbike", + "cradle", + "oven", + "ball", + "food, solid food", + "step, stair", + "tank, storage tank", + "trade name", + "microwave", + "pot", + "animal", + "bicycle", + "lake", + "dishwasher", + "screen", + "blanket, cover", + "sculpture", + "hood, exhaust hood", + "sconce", + "vase", + "traffic light", + "tray", + "trash can", + "fan", + "pier", + "crt screen", + "plate", + "monitor", + "bulletin board", + "shower", + "radiator", + "glass, drinking glass", + "clock", + "flag" + ], + "im_size": 640, + "task": "semseg", + "config": { + "num_classes": 150, + "backbone_config": { + "use_pretrained": false, + "backbone_url": null, + "model_type": "resnet", + "in_chans": 3, + "depth": 101, + "variant": "d", + "freeze_at": -1, + "num_stages": 4, + "freeze_norm": false, + "act": "relu", + "pretrained": false + }, + "num_queries": 100, + "resolution": 640, + "pixel_mean": [ + 123.675, + 116.28, + 103.53 + ], + "pixel_std": [ + 58.395, + 57.12, + 57.375 + ], + "size_divisibility": 0, + "pixel_decoder_out_dim": 128, + "pixel_decoder_feat_dim": 128, + "pixel_decoder_transformer_layers": 0, + "pixel_decoder_transformer_dropout": 0.0, + "pixel_decoder_transformer_nheads": 8, + "pixel_decoder_transformer_dim_feedforward": 1024, + "transformer_predictor_out_dim": 128, + "transformer_predictor_hidden_dim": 256, + "transformer_predictor_dec_layers": 6, + "transformer_predictor_dim_feedforward": 1024, + "head_out_dim": 128, + "cls_sigmoid": false, + "postprocessing_type": "semantic", + "mask_threshold": 0.5, + "predict_all_pixels": true, + "use_mask_score": false, + "threshold": 0.5, + "top_k": 100, + "criterion_deep_supervision": true, + "criterion_eos_coef": 0.1, + "criterion_num_points": 12544, + "weight_dict_loss_dice": 5, + "weight_dict_loss_mask": 5, + "weight_dict_loss_ce": 2, + "matcher_cost_class": 2, + "matcher_cost_mask": 5, + "matcher_cost_dice": 5 + }, + "focoos_model": "fai-mf-l-ade", + "ref": null, + "status": "TRAINING_COMPLETED", + "description": "MaskFormer Large model (ADE20K)", + "train_args": null, + "weights_uri": "https://public.focoos.ai/pretrained_models/fai-mf-l-ade/model_final.pth", + "val_dataset": "ade20k_semseg", + "val_metrics": { + "sem_seg/mIoU": 48.1376632510406, + "sem_seg/fwIoU": 71.93938404206054, + "sem_seg/IoU-wall": 76.87925279788226, + "sem_seg/IoU-building": 81.01769172399307, + "sem_seg/IoU-sky": 94.35270174426756, + "sem_seg/IoU-floor": 82.54761647921946, + "sem_seg/IoU-tree": 74.8614765287732, + "sem_seg/IoU-ceiling": 82.07077336681495, + "sem_seg/IoU-road, route": 81.92313812698548, + "sem_seg/IoU-bed": 88.77547315565856, + "sem_seg/IoU-window ": 61.42339735135045, + "sem_seg/IoU-grass": 71.41488830322139, + "sem_seg/IoU-cabinet": 57.46382728812264, + "sem_seg/IoU-sidewalk, pavement": 65.0377001610649, + "sem_seg/IoU-person": 82.59726740122927, + "sem_seg/IoU-earth, ground": 40.42416966575178, + "sem_seg/IoU-door": 50.27934307270097, + "sem_seg/IoU-table": 60.675570914660916, + "sem_seg/IoU-mountain, mount": 62.51139625668593, + "sem_seg/IoU-plant": 51.73626868617156, + "sem_seg/IoU-curtain": 73.03600888419389, + "sem_seg/IoU-chair": 58.71226867888058, + "sem_seg/IoU-car": 84.01160624156311, + "sem_seg/IoU-water": 51.45573272418649, + "sem_seg/IoU-painting, picture": 71.44316376974487, + "sem_seg/IoU-sofa": 64.48494249588343, + "sem_seg/IoU-shelf": 37.99818640666016, + "sem_seg/IoU-house": 39.72073786030725, + "sem_seg/IoU-sea": 58.82283997048434, + "sem_seg/IoU-mirror": 61.9515684001181, + "sem_seg/IoU-rug": 68.36089104592423, + "sem_seg/IoU-field": 32.823747667093144, + "sem_seg/IoU-armchair": 42.43020577641985, + "sem_seg/IoU-seat": 59.9649969517213, + "sem_seg/IoU-fence": 47.8538247520365, + "sem_seg/IoU-desk": 44.3914129493583, + "sem_seg/IoU-rock, stone": 44.58528341133645, + "sem_seg/IoU-wardrobe, closet, press": 42.24172356874203, + "sem_seg/IoU-lamp": 67.53542511817308, + "sem_seg/IoU-tub": 77.15479967791059, + "sem_seg/IoU-rail": 34.45154047373398, + "sem_seg/IoU-cushion": 61.08842779350256, + "sem_seg/IoU-base, pedestal, stand": 27.382814064028278, + "sem_seg/IoU-box": 23.725020027464293, + "sem_seg/IoU-column, pillar": 49.43377765860644, + "sem_seg/IoU-signboard, sign": 39.95113488924258, + "sem_seg/IoU-chest of drawers, chest, bureau, dresser": 39.64630988610749, + "sem_seg/IoU-counter": 26.62345942735981, + "sem_seg/IoU-sand": 13.737976011939562, + "sem_seg/IoU-sink": 70.72706943974966, + "sem_seg/IoU-skyscraper": 26.980206174698303, + "sem_seg/IoU-fireplace": 60.74672508229744, + "sem_seg/IoU-refrigerator, icebox": 71.94750587133447, + "sem_seg/IoU-grandstand, covered stand": 37.93978421254638, + "sem_seg/IoU-path": 18.799431846351492, + "sem_seg/IoU-stairs": 35.54898127531722, + "sem_seg/IoU-runway": 61.73240160777631, + "sem_seg/IoU-case, display case, showcase, vitrine": 59.0242210919637, + "sem_seg/IoU-pool table, billiard table, snooker table": 86.41246713157337, + "sem_seg/IoU-pillow": 61.25939895279965, + "sem_seg/IoU-screen door, screen": 63.07916019282101, + "sem_seg/IoU-stairway, staircase": 28.800568258693005, + "sem_seg/IoU-river": 21.47329232026515, + "sem_seg/IoU-bridge, span": 68.4918517243358, + "sem_seg/IoU-bookcase": 29.961573524693275, + "sem_seg/IoU-blind, screen": 43.99017719475722, + "sem_seg/IoU-coffee table": 64.20723379412662, + "sem_seg/IoU-toilet, can, commode, crapper, pot, potty, stool, throne": 86.97222315974214, + "sem_seg/IoU-flower": 42.30877341168343, + "sem_seg/IoU-book": 47.355194276662175, + "sem_seg/IoU-hill": 11.657622549677281, + "sem_seg/IoU-bench": 42.17508465577778, + "sem_seg/IoU-countertop": 57.47374218431914, + "sem_seg/IoU-stove": 72.33375400446559, + "sem_seg/IoU-palm, palm tree": 53.23204315678286, + "sem_seg/IoU-kitchen island": 34.73992579157761, + "sem_seg/IoU-computer": 57.539437073017076, + "sem_seg/IoU-swivel chair": 40.10559429860908, + "sem_seg/IoU-boat": 39.717321783040596, + "sem_seg/IoU-bar": 34.582708842268964, + "sem_seg/IoU-arcade machine": 68.13367776407436, + "sem_seg/IoU-hovel, hut, hutch, shack, shanty": 35.746076867419696, + "sem_seg/IoU-bus": 78.51599481059853, + "sem_seg/IoU-towel": 64.72488749633864, + "sem_seg/IoU-light": 59.40680467913945, + "sem_seg/IoU-truck": 38.610603290676416, + "sem_seg/IoU-tower": 37.61415220029418, + "sem_seg/IoU-chandelier": 68.59354268022999, + "sem_seg/IoU-awning, sunshade, sunblind": 23.02798861928483, + "sem_seg/IoU-street lamp": 35.15573712586303, + "sem_seg/IoU-booth": 41.84981926598243, + "sem_seg/IoU-tv": 72.35410255346461, + "sem_seg/IoU-plane": 58.17951700852985, + "sem_seg/IoU-dirt track": 32.76741903827281, + "sem_seg/IoU-clothes": 32.28405682944388, + "sem_seg/IoU-pole": 24.1096920566942, + "sem_seg/IoU-land, ground, soil": 1.979211767735509, + "sem_seg/IoU-bannister, banister, balustrade, balusters, handrail": 14.7526215578732, + "sem_seg/IoU-escalator, moving staircase, moving stairway": 57.83769975584714, + "sem_seg/IoU-ottoman, pouf, pouffe, puff, hassock": 56.7015997241338, + "sem_seg/IoU-bottle": 32.21523668639053, + "sem_seg/IoU-buffet, counter, sideboard": 36.066576318570796, + "sem_seg/IoU-poster, posting, placard, notice, bill, card": 23.610651548000614, + "sem_seg/IoU-stage": 22.48318953207619, + "sem_seg/IoU-van": 43.895843798315596, + "sem_seg/IoU-ship": 4.053670237357288, + "sem_seg/IoU-fountain": 20.800399135880948, + "sem_seg/IoU-conveyer belt, conveyor belt, conveyer, conveyor, transporter": 58.8822450200703, + "sem_seg/IoU-canopy": 30.451625450742327, + "sem_seg/IoU-washer, automatic washer, washing machine": 68.8261797102388, + "sem_seg/IoU-plaything, toy": 30.011579978783537, + "sem_seg/IoU-pool": 42.81488035584768, + "sem_seg/IoU-stool": 47.81507760933666, + "sem_seg/IoU-barrel, cask": 33.22849025974026, + "sem_seg/IoU-basket, handbasket": 39.23428871343161, + "sem_seg/IoU-falls": 51.040683211564456, + "sem_seg/IoU-tent": 50.68900367061301, + "sem_seg/IoU-bag": 15.772638394197303, + "sem_seg/IoU-minibike, motorbike": 56.67889115608743, + "sem_seg/IoU-cradle": 73.86097450569348, + "sem_seg/IoU-oven": 15.456259426847662, + "sem_seg/IoU-ball": 40.28262242852042, + "sem_seg/IoU-food, solid food": 60.12751528407666, + "sem_seg/IoU-step, stair": 25.99033946786119, + "sem_seg/IoU-tank, storage tank": 58.2154100131278, + "sem_seg/IoU-trade name": 29.927727469494574, + "sem_seg/IoU-microwave": 39.97421380122204, + "sem_seg/IoU-pot": 38.04428536549145, + "sem_seg/IoU-animal": 66.54475005813943, + "sem_seg/IoU-bicycle": 60.03523441403066, + "sem_seg/IoU-lake": 0.0, + "sem_seg/IoU-dishwasher": 66.4518458247342, + "sem_seg/IoU-screen": 50.53593861313988, + "sem_seg/IoU-blanket, cover": 23.40943700144584, + "sem_seg/IoU-sculpture": 54.66796975464226, + "sem_seg/IoU-hood, exhaust hood": 65.29707702369572, + "sem_seg/IoU-sconce": 50.34859551696738, + "sem_seg/IoU-vase": 39.11461212456238, + "sem_seg/IoU-traffic light": 37.221289233938684, + "sem_seg/IoU-tray": 14.151292851605609, + "sem_seg/IoU-trash can": 46.73286176297216, + "sem_seg/IoU-fan": 64.03763453633967, + "sem_seg/IoU-pier": 55.75420887430447, + "sem_seg/IoU-crt screen": 3.882669988887804, + "sem_seg/IoU-plate": 56.096804219671114, + "sem_seg/IoU-monitor": 10.39277798581101, + "sem_seg/IoU-bulletin board": 40.57314508336139, + "sem_seg/IoU-shower": 3.940208794685226, + "sem_seg/IoU-radiator": 63.80167089457753, + "sem_seg/IoU-glass, drinking glass": 20.969039313806626, + "sem_seg/IoU-clock": 31.677298259466273, + "sem_seg/IoU-flag": 59.89016739282508, + "sem_seg/mACC": 62.190735556001776, + "sem_seg/pACC": 82.44468526112031, + "sem_seg/ACC-wall": 86.47999178354733, + "sem_seg/ACC-building": 91.64508448051089, + "sem_seg/ACC-sky": 97.13433633488509, + "sem_seg/ACC-floor": 89.78555229513698, + "sem_seg/ACC-tree": 86.84372536044987, + "sem_seg/ACC-ceiling": 89.18269747004544, + "sem_seg/ACC-road, route": 87.67705466366714, + "sem_seg/ACC-bed": 94.4216702124137, + "sem_seg/ACC-window ": 78.34252756502573, + "sem_seg/ACC-grass": 84.36288410745615, + "sem_seg/ACC-cabinet": 71.60387808797047, + "sem_seg/ACC-sidewalk, pavement": 83.57897550344622, + "sem_seg/ACC-person": 89.90635487833102, + "sem_seg/ACC-earth, ground": 59.03734107528575, + "sem_seg/ACC-door": 66.31817531610228, + "sem_seg/ACC-table": 74.78036036676585, + "sem_seg/ACC-mountain, mount": 75.58217469706167, + "sem_seg/ACC-plant": 65.59471824273562, + "sem_seg/ACC-curtain": 85.33812532160509, + "sem_seg/ACC-chair": 71.7045617077896, + "sem_seg/ACC-car": 90.60239353701725, + "sem_seg/ACC-water": 64.39499381392993, + "sem_seg/ACC-painting, picture": 86.03141068897384, + "sem_seg/ACC-sofa": 82.36400872872306, + "sem_seg/ACC-shelf": 54.45214478797721, + "sem_seg/ACC-house": 60.794061134211866, + "sem_seg/ACC-sea": 87.99172982527973, + "sem_seg/ACC-mirror": 72.066469896027, + "sem_seg/ACC-rug": 77.73433272975177, + "sem_seg/ACC-field": 52.10323975654361, + "sem_seg/ACC-armchair": 59.049227205083675, + "sem_seg/ACC-seat": 84.8535781155079, + "sem_seg/ACC-fence": 66.58638934951895, + "sem_seg/ACC-desk": 73.52836979049616, + "sem_seg/ACC-rock, stone": 66.47781469892364, + "sem_seg/ACC-wardrobe, closet, press": 68.89418944888125, + "sem_seg/ACC-lamp": 80.38119426724919, + "sem_seg/ACC-tub": 82.3569070514659, + "sem_seg/ACC-rail": 51.17174661638111, + "sem_seg/ACC-cushion": 72.04217608999434, + "sem_seg/ACC-base, pedestal, stand": 53.639387831246864, + "sem_seg/ACC-box": 31.999853366386994, + "sem_seg/ACC-column, pillar": 58.727676302145106, + "sem_seg/ACC-signboard, sign": 55.45011300670749, + "sem_seg/ACC-chest of drawers, chest, bureau, dresser": 60.35730471776728, + "sem_seg/ACC-counter": 33.68074117860398, + "sem_seg/ACC-sand": 22.310561965789287, + "sem_seg/ACC-sink": 80.3442018516164, + "sem_seg/ACC-skyscraper": 31.029677834929476, + "sem_seg/ACC-fireplace": 82.27854586866985, + "sem_seg/ACC-refrigerator, icebox": 85.24408770712634, + "sem_seg/ACC-grandstand, covered stand": 66.37280379990798, + "sem_seg/ACC-path": 26.344658117314086, + "sem_seg/ACC-stairs": 44.13547545051281, + "sem_seg/ACC-runway": 76.74825301335598, + "sem_seg/ACC-case, display case, showcase, vitrine": 78.26864494014495, + "sem_seg/ACC-pool table, billiard table, snooker table": 95.84110964205598, + "sem_seg/ACC-pillow": 73.98458477691719, + "sem_seg/ACC-screen door, screen": 82.66135624562942, + "sem_seg/ACC-stairway, staircase": 44.43512810164633, + "sem_seg/ACC-river": 30.313479422905598, + "sem_seg/ACC-bridge, span": 82.73413957486038, + "sem_seg/ACC-bookcase": 48.00066090482766, + "sem_seg/ACC-blind, screen": 50.01671673858388, + "sem_seg/ACC-coffee table": 82.00178943739431, + "sem_seg/ACC-toilet, can, commode, crapper, pot, potty, stool, throne": 90.6606014565365, + "sem_seg/ACC-flower": 60.10175154901933, + "sem_seg/ACC-book": 67.90048870037253, + "sem_seg/ACC-hill": 22.898282785896587, + "sem_seg/ACC-bench": 57.0263015020812, + "sem_seg/ACC-countertop": 73.78557810971603, + "sem_seg/ACC-stove": 79.27846725591081, + "sem_seg/ACC-palm, palm tree": 76.19936062900283, + "sem_seg/ACC-kitchen island": 72.65480163144234, + "sem_seg/ACC-computer": 66.03802500507567, + "sem_seg/ACC-swivel chair": 64.69983314779917, + "sem_seg/ACC-boat": 53.804648669436375, + "sem_seg/ACC-bar": 42.533512669659906, + "sem_seg/ACC-arcade machine": 74.87795689946445, + "sem_seg/ACC-hovel, hut, hutch, shack, shanty": 41.221502565016735, + "sem_seg/ACC-bus": 95.19653852240064, + "sem_seg/ACC-towel": 78.67637340554946, + "sem_seg/ACC-light": 74.62666156752309, + "sem_seg/ACC-truck": 52.45723962743438, + "sem_seg/ACC-tower": 53.80585455091228, + "sem_seg/ACC-chandelier": 81.68370934314375, + "sem_seg/ACC-awning, sunshade, sunblind": 35.79580726803878, + "sem_seg/ACC-street lamp": 51.988757975416355, + "sem_seg/ACC-booth": 44.62073500504608, + "sem_seg/ACC-tv": 81.3149681022046, + "sem_seg/ACC-plane": 65.59291838801047, + "sem_seg/ACC-dirt track": 51.3899844167837, + "sem_seg/ACC-clothes": 60.7817276777303, + "sem_seg/ACC-pole": 41.774975430382405, + "sem_seg/ACC-land, ground, soil": 3.6208699613787676, + "sem_seg/ACC-bannister, banister, balustrade, balusters, handrail": 21.194862247480934, + "sem_seg/ACC-escalator, moving staircase, moving stairway": 75.73549986973764, + "sem_seg/ACC-ottoman, pouf, pouffe, puff, hassock": 68.73605083131594, + "sem_seg/ACC-bottle": 41.29545059494651, + "sem_seg/ACC-buffet, counter, sideboard": 42.96927955075691, + "sem_seg/ACC-poster, posting, placard, notice, bill, card": 38.29336473151352, + "sem_seg/ACC-stage": 32.177558691842286, + "sem_seg/ACC-van": 64.51311355170877, + "sem_seg/ACC-ship": 6.609315846403462, + "sem_seg/ACC-fountain": 21.86061862694735, + "sem_seg/ACC-conveyer belt, conveyor belt, conveyer, conveyor, transporter": 63.94156500785301, + "sem_seg/ACC-canopy": 45.210343019110304, + "sem_seg/ACC-washer, automatic washer, washing machine": 72.21580387026623, + "sem_seg/ACC-plaything, toy": 53.397400801083485, + "sem_seg/ACC-pool": 61.7208107732889, + "sem_seg/ACC-stool": 76.79741730329236, + "sem_seg/ACC-barrel, cask": 74.52497439981795, + "sem_seg/ACC-basket, handbasket": 50.64080615291723, + "sem_seg/ACC-falls": 61.025578574138514, + "sem_seg/ACC-tent": 98.42530823074185, + "sem_seg/ACC-bag": 22.82616991422684, + "sem_seg/ACC-minibike, motorbike": 67.57455714998, + "sem_seg/ACC-cradle": 86.25832743099396, + "sem_seg/ACC-oven": 43.9773559185116, + "sem_seg/ACC-ball": 70.30583385264369, + "sem_seg/ACC-food, solid food": 82.6676587359255, + "sem_seg/ACC-step, stair": 43.860787595204755, + "sem_seg/ACC-tank, storage tank": 61.30472273659341, + "sem_seg/ACC-trade name": 39.83550553384655, + "sem_seg/ACC-microwave": 45.02631231045172, + "sem_seg/ACC-pot": 45.74551155555017, + "sem_seg/ACC-animal": 70.61785755806245, + "sem_seg/ACC-bicycle": 79.6303077862063, + "sem_seg/ACC-lake": 0.0, + "sem_seg/ACC-dishwasher": 77.17609657494383, + "sem_seg/ACC-screen": 68.94103507626565, + "sem_seg/ACC-blanket, cover": 29.578844270323213, + "sem_seg/ACC-sculpture": 69.60166341950588, + "sem_seg/ACC-hood, exhaust hood": 69.61953344168558, + "sem_seg/ACC-sconce": 63.21592234979793, + "sem_seg/ACC-vase": 64.35354660724707, + "sem_seg/ACC-traffic light": 56.31655548817464, + "sem_seg/ACC-tray": 25.877431879443023, + "sem_seg/ACC-trash can": 64.71798435759119, + "sem_seg/ACC-fan": 81.80008255031927, + "sem_seg/ACC-pier": 87.00583053866384, + "sem_seg/ACC-crt screen": 12.57899282544189, + "sem_seg/ACC-plate": 69.25087150247512, + "sem_seg/ACC-monitor": 13.481331722942812, + "sem_seg/ACC-bulletin board": 57.81180883076751, + "sem_seg/ACC-shower": 24.123571566918457, + "sem_seg/ACC-radiator": 72.78817529962983, + "sem_seg/ACC-glass, drinking glass": 23.90753208731807, + "sem_seg/ACC-clock": 43.36810164991466, + "sem_seg/ACC-flag": 65.05363798993652 + }, + "focoos_version": "0.15.0", + "latency": [ + { + "fps": 73, + "engine": "onnx.TensorrtExecutionProvider", + "min": 13.355, + "max": 13.964, + "mean": 13.631, + "std": 0.12, + "im_size": 640, + "device": "Tesla T4" + }, + { + "fps": 22, + "engine": "torchscript", + "min": 44.216, + "max": 46.778, + "mean": 45.407, + "std": 0.641, + "im_size": 640, + "device": "Tesla T4" + }, + { + "fps": 16, + "engine": "onnx.CUDAExecutionProvider", + "min": 59.966, + "max": 61.467, + "mean": 60.646, + "std": 0.397, + "im_size": 640, + "device": "Tesla T4" + } + ], + "updated_at": null +} diff --git a/focoos/model_registry/fai-mf-l-coco-ins.json b/focoos/model_registry/fai-mf-l-coco-ins.json new file mode 100644 index 00000000..559a949b --- /dev/null +++ b/focoos/model_registry/fai-mf-l-coco-ins.json @@ -0,0 +1,273 @@ +{ + "name": "fai-mf-l-coco-ins", + "model_family": "fai_mf", + "classes": [ + "person", + "bicycle", + "car", + "motorcycle", + "airplane", + "bus", + "train", + "truck", + "boat", + "traffic light", + "fire hydrant", + "stop sign", + "parking meter", + "bench", + "bird", + "cat", + "dog", + "horse", + "sheep", + "cow", + "elephant", + "bear", + "zebra", + "giraffe", + "backpack", + "umbrella", + "handbag", + "tie", + "suitcase", + "frisbee", + "skis", + "snowboard", + "sports ball", + "kite", + "baseball bat", + "baseball glove", + "skateboard", + "surfboard", + "tennis racket", + "bottle", + "wine glass", + "cup", + "fork", + "knife", + "spoon", + "bowl", + "banana", + "apple", + "sandwich", + "orange", + "broccoli", + "carrot", + "hot dog", + "pizza", + "donut", + "cake", + "chair", + "couch", + "potted plant", + "bed", + "dining table", + "toilet", + "tv", + "laptop", + "mouse", + "remote", + "keyboard", + "cell phone", + "microwave", + "oven", + "toaster", + "sink", + "refrigerator", + "book", + "clock", + "vase", + "scissors", + "teddy bear", + "hair drier", + "toothbrush" + ], + "im_size": 1024, + "task": "instseg", + "config": { + "num_classes": 80, + "backbone_config": { + "use_pretrained": false, + "backbone_url": null, + "model_type": "resnet", + "in_chans": 3, + "depth": 101, + "variant": "d", + "freeze_at": -1, + "num_stages": 4, + "freeze_norm": false, + "act": "relu", + "pretrained": false + }, + "num_queries": 100, + "resolution": 1024, + "pixel_mean": [ + 123.675, + 116.28, + 103.53 + ], + "pixel_std": [ + 58.395, + 57.12, + 57.375 + ], + "size_divisibility": 0, + "pixel_decoder_out_dim": 256, + "pixel_decoder_feat_dim": 256, + "pixel_decoder_transformer_layers": 6, + "pixel_decoder_transformer_dropout": 0.0, + "pixel_decoder_transformer_nheads": 8, + "pixel_decoder_transformer_dim_feedforward": 1024, + "transformer_predictor_out_dim": 256, + "transformer_predictor_hidden_dim": 256, + "transformer_predictor_dec_layers": 9, + "transformer_predictor_dim_feedforward": 2048, + "head_out_dim": 256, + "cls_sigmoid": false, + "postprocessing_type": "instance", + "mask_threshold": 0.5, + "predict_all_pixels": false, + "use_mask_score": true, + "threshold": 0.5, + "top_k": 100, + "criterion_deep_supervision": true, + "criterion_eos_coef": 0.1, + "criterion_num_points": 12544, + "weight_dict_loss_dice": 5, + "weight_dict_loss_mask": 5, + "weight_dict_loss_ce": 2, + "matcher_cost_class": 2, + "matcher_cost_mask": 5, + "matcher_cost_dice": 5 + }, + "focoos_model": "fai-mf-l-coco-ins", + "ref": null, + "status": "TRAINING_COMPLETED", + "description": "MaskFormer Large model (COCO Instance Segmentation)", + "train_args": null, + "weights_uri": "https://public.focoos.ai/pretrained_models/fai-mf-l-coco-ins/model_final.pth", + "val_dataset": "coco_2017_instance", + "val_metrics": { + "segm/AP": 44.42834348734414, + "segm/AP50": 68.09361878112426, + "segm/AP75": 47.419716893326466, + "segm/APs": 24.0404122595052, + "segm/APm": 47.88059543674593, + "segm/APl": 64.2145366697028, + "segm/AP-person": 50.7656550952437, + "segm/AP-bicycle": 25.386274315474328, + "segm/AP-car": 44.84819088691937, + "segm/AP-motorcycle": 41.204617615206246, + "segm/AP-airplane": 57.70686307323889, + "segm/AP-bus": 70.50854527251907, + "segm/AP-train": 69.21820724553636, + "segm/AP-truck": 44.7937981512925, + "segm/AP-boat": 27.653516835480964, + "segm/AP-traffic light": 27.87906688871007, + "segm/AP-fire hydrant": 68.50244689011815, + "segm/AP-stop sign": 65.8274906662595, + "segm/AP-parking meter": 49.30672199875216, + "segm/AP-bench": 24.07632084231725, + "segm/AP-bird": 33.666789256563305, + "segm/AP-cat": 78.72939069785745, + "segm/AP-dog": 68.8712396711587, + "segm/AP-horse": 49.85528211475573, + "segm/AP-sheep": 53.28787128735266, + "segm/AP-cow": 54.78248335075878, + "segm/AP-elephant": 65.74658088892514, + "segm/AP-bear": 80.55734810629701, + "segm/AP-zebra": 64.33571984606984, + "segm/AP-giraffe": 61.2719415681915, + "segm/AP-backpack": 25.616830696832398, + "segm/AP-umbrella": 53.72448666487782, + "segm/AP-handbag": 24.07182580172422, + "segm/AP-tie": 37.25833159730765, + "segm/AP-suitcase": 47.46399475645519, + "segm/AP-frisbee": 68.79804600414414, + "segm/AP-skis": 10.123328684064807, + "segm/AP-snowboard": 32.562345465250615, + "segm/AP-sports ball": 47.65233493877522, + "segm/AP-kite": 33.84306015370146, + "segm/AP-baseball bat": 34.30349074586953, + "segm/AP-baseball glove": 44.59084723472245, + "segm/AP-skateboard": 43.32162001509962, + "segm/AP-surfboard": 41.69805123455388, + "segm/AP-tennis racket": 61.51569991519229, + "segm/AP-bottle": 41.29936070365056, + "segm/AP-wine glass": 39.661089569956104, + "segm/AP-cup": 49.92504321044473, + "segm/AP-fork": 26.114917610348076, + "segm/AP-knife": 20.53582631361108, + "segm/AP-spoon": 22.472645223344397, + "segm/AP-bowl": 45.26708783848328, + "segm/AP-banana": 24.99973102913072, + "segm/AP-apple": 22.965687298341354, + "segm/AP-sandwich": 44.47141404757418, + "segm/AP-orange": 33.33488516776828, + "segm/AP-broccoli": 24.123069914920347, + "segm/AP-carrot": 20.981537952081244, + "segm/AP-hot dog": 42.230826190401615, + "segm/AP-pizza": 57.004916553279294, + "segm/AP-donut": 52.8913157093937, + "segm/AP-cake": 44.14138795902856, + "segm/AP-chair": 26.363689512080995, + "segm/AP-couch": 47.46797591123429, + "segm/AP-potted plant": 26.326250250184753, + "segm/AP-bed": 44.0221876434514, + "segm/AP-dining table": 22.146661157170886, + "segm/AP-toilet": 65.64791068015712, + "segm/AP-tv": 65.86313070117099, + "segm/AP-laptop": 68.65956020790513, + "segm/AP-mouse": 62.396908010340724, + "segm/AP-remote": 39.820368417602396, + "segm/AP-keyboard": 53.1704375656706, + "segm/AP-cell phone": 42.33677943110273, + "segm/AP-microwave": 65.18759525376858, + "segm/AP-oven": 39.39076548977193, + "segm/AP-toaster": 49.95054017309993, + "segm/AP-sink": 43.835728604278316, + "segm/AP-refrigerator": 63.57354774683771, + "segm/AP-book": 14.214648719121037, + "segm/AP-clock": 52.39945033844082, + "segm/AP-vase": 40.23085922246435, + "segm/AP-scissors": 32.678909771006545, + "segm/AP-teddy bear": 52.82921008406121, + "segm/AP-hair drier": 16.110094531634534, + "segm/AP-toothbrush": 21.896870799646834 + }, + "focoos_version": "0.15.0", + "latency": [ + { + "fps": 48, + "engine": "onnx.TensorrtExecutionProvider", + "min": 20.185, + "max": 21.059, + "mean": 20.455, + "std": 0.213, + "im_size": 640, + "device": "Tesla T4" + }, + { + "fps": 16, + "engine": "torchscript", + "min": 60.141, + "max": 67.003, + "mean": 61.578, + "std": 1.077, + "im_size": 640, + "device": "Tesla T4" + }, + { + "fps": 12, + "engine": "onnx.CUDAExecutionProvider", + "min": 80.054, + "max": 81.955, + "mean": 80.99, + "std": 0.491, + "im_size": 640, + "device": "Tesla T4" + } + ], + "updated_at": null +} diff --git a/focoos/model_registry/fai-mf-m-ade.json b/focoos/model_registry/fai-mf-m-ade.json new file mode 100644 index 00000000..f6b71dc0 --- /dev/null +++ b/focoos/model_registry/fai-mf-m-ade.json @@ -0,0 +1,569 @@ +{ + "name": "fai-mf-m-ade", + "model_family": "fai_mf", + "classes": [ + "wall", + "building", + "sky", + "floor", + "tree", + "ceiling", + "road, route", + "bed", + "window ", + "grass", + "cabinet", + "sidewalk, pavement", + "person", + "earth, ground", + "door", + "table", + "mountain, mount", + "plant", + "curtain", + "chair", + "car", + "water", + "painting, picture", + "sofa", + "shelf", + "house", + "sea", + "mirror", + "rug", + "field", + "armchair", + "seat", + "fence", + "desk", + "rock, stone", + "wardrobe, closet, press", + "lamp", + "tub", + "rail", + "cushion", + "base, pedestal, stand", + "box", + "column, pillar", + "signboard, sign", + "chest of drawers, chest, bureau, dresser", + "counter", + "sand", + "sink", + "skyscraper", + "fireplace", + "refrigerator, icebox", + "grandstand, covered stand", + "path", + "stairs", + "runway", + "case, display case, showcase, vitrine", + "pool table, billiard table, snooker table", + "pillow", + "screen door, screen", + "stairway, staircase", + "river", + "bridge, span", + "bookcase", + "blind, screen", + "coffee table", + "toilet, can, commode, crapper, pot, potty, stool, throne", + "flower", + "book", + "hill", + "bench", + "countertop", + "stove", + "palm, palm tree", + "kitchen island", + "computer", + "swivel chair", + "boat", + "bar", + "arcade machine", + "hovel, hut, hutch, shack, shanty", + "bus", + "towel", + "light", + "truck", + "tower", + "chandelier", + "awning, sunshade, sunblind", + "street lamp", + "booth", + "tv", + "plane", + "dirt track", + "clothes", + "pole", + "land, ground, soil", + "bannister, banister, balustrade, balusters, handrail", + "escalator, moving staircase, moving stairway", + "ottoman, pouf, pouffe, puff, hassock", + "bottle", + "buffet, counter, sideboard", + "poster, posting, placard, notice, bill, card", + "stage", + "van", + "ship", + "fountain", + "conveyer belt, conveyor belt, conveyer, conveyor, transporter", + "canopy", + "washer, automatic washer, washing machine", + "plaything, toy", + "pool", + "stool", + "barrel, cask", + "basket, handbasket", + "falls", + "tent", + "bag", + "minibike, motorbike", + "cradle", + "oven", + "ball", + "food, solid food", + "step, stair", + "tank, storage tank", + "trade name", + "microwave", + "pot", + "animal", + "bicycle", + "lake", + "dishwasher", + "screen", + "blanket, cover", + "sculpture", + "hood, exhaust hood", + "sconce", + "vase", + "traffic light", + "tray", + "trash can", + "fan", + "pier", + "crt screen", + "plate", + "monitor", + "bulletin board", + "shower", + "radiator", + "glass, drinking glass", + "clock", + "flag" + ], + "im_size": 640, + "task": "semseg", + "config": { + "num_classes": 150, + "backbone_config": { + "use_pretrained": false, + "backbone_url": null, + "model_type": "stdc", + "in_chans": 3, + "base": 64, + "layers": [ + 4, + 5, + 3 + ], + "out_features": [ + "res2", + "res3", + "res4", + "res5" + ], + "block_num": 4, + "block_type": "cat", + "use_conv_last": false + }, + "num_queries": 100, + "resolution": 640, + "pixel_mean": [ + 123.675, + 116.28, + 103.53 + ], + "pixel_std": [ + 58.395, + 57.12, + 57.375 + ], + "size_divisibility": 0, + "pixel_decoder_out_dim": 128, + "pixel_decoder_feat_dim": 128, + "pixel_decoder_transformer_layers": 0, + "pixel_decoder_transformer_dropout": 0.0, + "pixel_decoder_transformer_nheads": 8, + "pixel_decoder_transformer_dim_feedforward": 1024, + "transformer_predictor_out_dim": 128, + "transformer_predictor_hidden_dim": 256, + "transformer_predictor_dec_layers": 3, + "transformer_predictor_dim_feedforward": 512, + "head_out_dim": 128, + "cls_sigmoid": false, + "postprocessing_type": "semantic", + "mask_threshold": 0.5, + "predict_all_pixels": true, + "use_mask_score": false, + "threshold": 0.5, + "top_k": 100, + "criterion_deep_supervision": true, + "criterion_eos_coef": 0.1, + "criterion_num_points": 12544, + "weight_dict_loss_dice": 5, + "weight_dict_loss_mask": 5, + "weight_dict_loss_ce": 2, + "matcher_cost_class": 2, + "matcher_cost_mask": 5, + "matcher_cost_dice": 5 + }, + "focoos_model": "fai-mf-m-ade", + "ref": null, + "status": "TRAINING_COMPLETED", + "description": "MaskFormer Medium model (ADE20K)", + "train_args": null, + "weights_uri": "https://public.focoos.ai/pretrained_models/fai-mf-m-ade/model_final.pth", + "val_dataset": "ade20k_semseg", + "val_metrics": { + "sem_seg/mIoU": 44.43417974246107, + "sem_seg/fwIoU": 69.54959624100037, + "sem_seg/IoU-wall": 74.52541568483917, + "sem_seg/IoU-building": 80.87678007157167, + "sem_seg/IoU-sky": 94.28387816236317, + "sem_seg/IoU-floor": 79.51933117358185, + "sem_seg/IoU-tree": 73.24843116749005, + "sem_seg/IoU-ceiling": 81.0369955144912, + "sem_seg/IoU-road, route": 80.4034544860636, + "sem_seg/IoU-bed": 86.41843401560853, + "sem_seg/IoU-window ": 58.36798064386618, + "sem_seg/IoU-grass": 67.10459462695833, + "sem_seg/IoU-cabinet": 56.83335019415616, + "sem_seg/IoU-sidewalk, pavement": 62.29783961175769, + "sem_seg/IoU-person": 80.8099002051856, + "sem_seg/IoU-earth, ground": 32.048387635273926, + "sem_seg/IoU-door": 43.835420215465106, + "sem_seg/IoU-table": 56.53405684384115, + "sem_seg/IoU-mountain, mount": 52.20287292327348, + "sem_seg/IoU-plant": 49.16959952233877, + "sem_seg/IoU-curtain": 71.26199775109227, + "sem_seg/IoU-chair": 52.55840519320657, + "sem_seg/IoU-car": 83.10713395446032, + "sem_seg/IoU-water": 47.51369455355022, + "sem_seg/IoU-painting, picture": 69.13792699128943, + "sem_seg/IoU-sofa": 61.036769555401904, + "sem_seg/IoU-shelf": 35.21841941697385, + "sem_seg/IoU-house": 40.00343394941623, + "sem_seg/IoU-sea": 58.07094054471068, + "sem_seg/IoU-mirror": 51.74523325387236, + "sem_seg/IoU-rug": 59.23666531202224, + "sem_seg/IoU-field": 28.83625856191025, + "sem_seg/IoU-armchair": 35.459101447129974, + "sem_seg/IoU-seat": 50.88927223330849, + "sem_seg/IoU-fence": 35.730236748811905, + "sem_seg/IoU-desk": 36.2717593558924, + "sem_seg/IoU-rock, stone": 35.5438560091291, + "sem_seg/IoU-wardrobe, closet, press": 39.20687504636596, + "sem_seg/IoU-lamp": 64.93928139157839, + "sem_seg/IoU-tub": 71.45613823337014, + "sem_seg/IoU-rail": 31.90719120667041, + "sem_seg/IoU-cushion": 54.54085779165242, + "sem_seg/IoU-base, pedestal, stand": 26.237278345157883, + "sem_seg/IoU-box": 19.752626029662437, + "sem_seg/IoU-column, pillar": 42.23309597850209, + "sem_seg/IoU-signboard, sign": 38.87772464062402, + "sem_seg/IoU-chest of drawers, chest, bureau, dresser": 37.13945678515184, + "sem_seg/IoU-counter": 22.54163601220866, + "sem_seg/IoU-sand": 35.11268472357238, + "sem_seg/IoU-sink": 62.791250862538305, + "sem_seg/IoU-skyscraper": 46.42447554938205, + "sem_seg/IoU-fireplace": 66.46015952466253, + "sem_seg/IoU-refrigerator, icebox": 70.65524334123553, + "sem_seg/IoU-grandstand, covered stand": 41.966864301424884, + "sem_seg/IoU-path": 21.019006304939506, + "sem_seg/IoU-stairs": 31.410525579116804, + "sem_seg/IoU-runway": 65.51333880323662, + "sem_seg/IoU-case, display case, showcase, vitrine": 42.270622738715325, + "sem_seg/IoU-pool table, billiard table, snooker table": 87.27392258442829, + "sem_seg/IoU-pillow": 53.25074500071908, + "sem_seg/IoU-screen door, screen": 54.2847761114015, + "sem_seg/IoU-stairway, staircase": 29.87511984485977, + "sem_seg/IoU-river": 13.009249611478179, + "sem_seg/IoU-bridge, span": 69.27287047693635, + "sem_seg/IoU-bookcase": 31.763616125491488, + "sem_seg/IoU-blind, screen": 32.813290876124825, + "sem_seg/IoU-coffee table": 62.65161183988099, + "sem_seg/IoU-toilet, can, commode, crapper, pot, potty, stool, throne": 83.40807010423212, + "sem_seg/IoU-flower": 33.436592475586785, + "sem_seg/IoU-book": 45.911138124327806, + "sem_seg/IoU-hill": 7.204557690298838, + "sem_seg/IoU-bench": 36.64198133945582, + "sem_seg/IoU-countertop": 53.48501683789466, + "sem_seg/IoU-stove": 73.36910976758419, + "sem_seg/IoU-palm, palm tree": 52.2913968547641, + "sem_seg/IoU-kitchen island": 33.09997358191493, + "sem_seg/IoU-computer": 57.09898295373156, + "sem_seg/IoU-swivel chair": 39.82839584903473, + "sem_seg/IoU-boat": 54.83629430431412, + "sem_seg/IoU-bar": 29.76504432332714, + "sem_seg/IoU-arcade machine": 14.424749033530865, + "sem_seg/IoU-hovel, hut, hutch, shack, shanty": 26.291490942698182, + "sem_seg/IoU-bus": 87.76737403242946, + "sem_seg/IoU-towel": 54.241166180314046, + "sem_seg/IoU-light": 54.450505526383644, + "sem_seg/IoU-truck": 30.108008225992656, + "sem_seg/IoU-tower": 19.69599796908873, + "sem_seg/IoU-chandelier": 62.58693464006263, + "sem_seg/IoU-awning, sunshade, sunblind": 19.083345535868798, + "sem_seg/IoU-street lamp": 29.9512850663531, + "sem_seg/IoU-booth": 28.01192342968727, + "sem_seg/IoU-tv": 70.69724537917834, + "sem_seg/IoU-plane": 49.145010143947445, + "sem_seg/IoU-dirt track": 2.8905020796660073, + "sem_seg/IoU-clothes": 31.291919088202075, + "sem_seg/IoU-pole": 21.186496554907198, + "sem_seg/IoU-land, ground, soil": 3.852000710285717, + "sem_seg/IoU-bannister, banister, balustrade, balusters, handrail": 10.412506025755802, + "sem_seg/IoU-escalator, moving staircase, moving stairway": 15.319627569830658, + "sem_seg/IoU-ottoman, pouf, pouffe, puff, hassock": 44.1849760988432, + "sem_seg/IoU-bottle": 16.91988153390515, + "sem_seg/IoU-buffet, counter, sideboard": 45.706752697741685, + "sem_seg/IoU-poster, posting, placard, notice, bill, card": 27.121207476215293, + "sem_seg/IoU-stage": 12.488856614890514, + "sem_seg/IoU-van": 38.97670330823517, + "sem_seg/IoU-ship": 88.2901223088944, + "sem_seg/IoU-fountain": 0.11784954050955196, + "sem_seg/IoU-conveyer belt, conveyor belt, conveyer, conveyor, transporter": 64.9463798544147, + "sem_seg/IoU-canopy": 29.275405886219495, + "sem_seg/IoU-washer, automatic washer, washing machine": 62.69406192020269, + "sem_seg/IoU-plaything, toy": 18.665088295443937, + "sem_seg/IoU-pool": 41.11777905584417, + "sem_seg/IoU-stool": 45.696409796790704, + "sem_seg/IoU-barrel, cask": 29.108075532342305, + "sem_seg/IoU-basket, handbasket": 18.99697881883331, + "sem_seg/IoU-falls": 60.6161923494966, + "sem_seg/IoU-tent": 89.74161458786305, + "sem_seg/IoU-bag": 10.234933346849814, + "sem_seg/IoU-minibike, motorbike": 70.64865927077193, + "sem_seg/IoU-cradle": 61.81715332399526, + "sem_seg/IoU-oven": 44.453978159126365, + "sem_seg/IoU-ball": 35.609379458515875, + "sem_seg/IoU-food, solid food": 58.56426004159773, + "sem_seg/IoU-step, stair": 16.349968622695897, + "sem_seg/IoU-tank, storage tank": 33.63431052058069, + "sem_seg/IoU-trade name": 28.45935424127602, + "sem_seg/IoU-microwave": 32.76936939912974, + "sem_seg/IoU-pot": 47.74303162737782, + "sem_seg/IoU-animal": 60.59231490159325, + "sem_seg/IoU-bicycle": 53.44492592842302, + "sem_seg/IoU-lake": 0.0, + "sem_seg/IoU-dishwasher": 59.39126932178641, + "sem_seg/IoU-screen": 48.90050810651262, + "sem_seg/IoU-blanket, cover": 18.036533817206667, + "sem_seg/IoU-sculpture": 40.717004536002236, + "sem_seg/IoU-hood, exhaust hood": 59.40229929438111, + "sem_seg/IoU-sconce": 39.54997156108817, + "sem_seg/IoU-vase": 36.036019300698, + "sem_seg/IoU-traffic light": 25.22705591782863, + "sem_seg/IoU-tray": 10.763256046449033, + "sem_seg/IoU-trash can": 37.14623364833754, + "sem_seg/IoU-fan": 55.641161411298356, + "sem_seg/IoU-pier": 58.90690432728444, + "sem_seg/IoU-crt screen": 3.572016105760803, + "sem_seg/IoU-plate": 38.94760882107827, + "sem_seg/IoU-monitor": 10.49958113845149, + "sem_seg/IoU-bulletin board": 45.962682966060804, + "sem_seg/IoU-shower": 7.024071683499127, + "sem_seg/IoU-radiator": 42.750784418062715, + "sem_seg/IoU-glass, drinking glass": 17.60234260614934, + "sem_seg/IoU-clock": 29.443216503525942, + "sem_seg/IoU-flag": 31.046459787037712, + "sem_seg/mACC": 57.91314941626411, + "sem_seg/pACC": 80.7299200374322, + "sem_seg/ACC-wall": 86.2222745146006, + "sem_seg/ACC-building": 91.38361720008584, + "sem_seg/ACC-sky": 96.94891695265581, + "sem_seg/ACC-floor": 88.04923611992544, + "sem_seg/ACC-tree": 85.73883294792958, + "sem_seg/ACC-ceiling": 87.88529514894253, + "sem_seg/ACC-road, route": 86.69464928250488, + "sem_seg/ACC-bed": 94.38652085351977, + "sem_seg/ACC-window ": 76.46016414432503, + "sem_seg/ACC-grass": 83.7628877816273, + "sem_seg/ACC-cabinet": 70.76456859997913, + "sem_seg/ACC-sidewalk, pavement": 78.72351622757505, + "sem_seg/ACC-person": 89.43875683177251, + "sem_seg/ACC-earth, ground": 44.58832147425468, + "sem_seg/ACC-door": 57.832366100707354, + "sem_seg/ACC-table": 71.84607978877239, + "sem_seg/ACC-mountain, mount": 73.01047576177723, + "sem_seg/ACC-plant": 67.49205744532199, + "sem_seg/ACC-curtain": 84.99787149392868, + "sem_seg/ACC-chair": 67.18559831796786, + "sem_seg/ACC-car": 89.82524426067377, + "sem_seg/ACC-water": 61.01583578713694, + "sem_seg/ACC-painting, picture": 83.97469731616997, + "sem_seg/ACC-sofa": 83.45264722228276, + "sem_seg/ACC-shelf": 53.62967573306303, + "sem_seg/ACC-house": 50.42855304489289, + "sem_seg/ACC-sea": 87.87327902913108, + "sem_seg/ACC-mirror": 62.331556182532324, + "sem_seg/ACC-rug": 71.76789282699814, + "sem_seg/ACC-field": 45.58152415036689, + "sem_seg/ACC-armchair": 51.02215748147817, + "sem_seg/ACC-seat": 77.76170476299191, + "sem_seg/ACC-fence": 54.78451162641268, + "sem_seg/ACC-desk": 57.22440195270557, + "sem_seg/ACC-rock, stone": 52.12302041282027, + "sem_seg/ACC-wardrobe, closet, press": 59.258973129165206, + "sem_seg/ACC-lamp": 76.13722929806747, + "sem_seg/ACC-tub": 84.63488696140212, + "sem_seg/ACC-rail": 49.148718413116505, + "sem_seg/ACC-cushion": 67.37950157919407, + "sem_seg/ACC-base, pedestal, stand": 62.90359989198867, + "sem_seg/ACC-box": 28.397707016876613, + "sem_seg/ACC-column, pillar": 56.57426547832864, + "sem_seg/ACC-signboard, sign": 55.66163604549431, + "sem_seg/ACC-chest of drawers, chest, bureau, dresser": 54.913072775384734, + "sem_seg/ACC-counter": 28.646369350550255, + "sem_seg/ACC-sand": 46.43533212323218, + "sem_seg/ACC-sink": 78.64874895816077, + "sem_seg/ACC-skyscraper": 61.73614860704576, + "sem_seg/ACC-fireplace": 82.73408270229294, + "sem_seg/ACC-refrigerator, icebox": 76.81301015009849, + "sem_seg/ACC-grandstand, covered stand": 72.75654974323265, + "sem_seg/ACC-path": 30.24452318527376, + "sem_seg/ACC-stairs": 39.23973297085536, + "sem_seg/ACC-runway": 84.61630617353039, + "sem_seg/ACC-case, display case, showcase, vitrine": 62.20758285914337, + "sem_seg/ACC-pool table, billiard table, snooker table": 95.75410001006136, + "sem_seg/ACC-pillow": 66.72149404249924, + "sem_seg/ACC-screen door, screen": 77.9045085914241, + "sem_seg/ACC-stairway, staircase": 43.038952363722395, + "sem_seg/ACC-river": 20.40575214338057, + "sem_seg/ACC-bridge, span": 80.62386389432332, + "sem_seg/ACC-bookcase": 50.29261357261896, + "sem_seg/ACC-blind, screen": 35.22690525798195, + "sem_seg/ACC-coffee table": 81.12595012558896, + "sem_seg/ACC-toilet, can, commode, crapper, pot, potty, stool, throne": 88.90149940646593, + "sem_seg/ACC-flower": 48.6957247265005, + "sem_seg/ACC-book": 69.33396198678393, + "sem_seg/ACC-hill": 10.905353548253697, + "sem_seg/ACC-bench": 56.56165168607106, + "sem_seg/ACC-countertop": 67.86100814856104, + "sem_seg/ACC-stove": 79.44490465948732, + "sem_seg/ACC-palm, palm tree": 76.31276257951637, + "sem_seg/ACC-kitchen island": 67.10366015149107, + "sem_seg/ACC-computer": 65.26468835345047, + "sem_seg/ACC-swivel chair": 56.51893348278902, + "sem_seg/ACC-boat": 85.1333477062563, + "sem_seg/ACC-bar": 37.67836868452289, + "sem_seg/ACC-arcade machine": 30.18474432116074, + "sem_seg/ACC-hovel, hut, hutch, shack, shanty": 45.6409570780597, + "sem_seg/ACC-bus": 93.24709555671546, + "sem_seg/ACC-towel": 76.86000124076506, + "sem_seg/ACC-light": 68.04227653582727, + "sem_seg/ACC-truck": 50.49562517640418, + "sem_seg/ACC-tower": 33.395633865436515, + "sem_seg/ACC-chandelier": 79.57986269299745, + "sem_seg/ACC-awning, sunshade, sunblind": 26.329651438289993, + "sem_seg/ACC-street lamp": 46.3404814807265, + "sem_seg/ACC-booth": 53.268349632005176, + "sem_seg/ACC-tv": 84.08291781175089, + "sem_seg/ACC-plane": 63.02489415604667, + "sem_seg/ACC-dirt track": 7.139421689528463, + "sem_seg/ACC-clothes": 46.30560434684835, + "sem_seg/ACC-pole": 31.934153424839057, + "sem_seg/ACC-land, ground, soil": 4.604520651487384, + "sem_seg/ACC-bannister, banister, balustrade, balusters, handrail": 14.81554063985106, + "sem_seg/ACC-escalator, moving staircase, moving stairway": 15.770429479818985, + "sem_seg/ACC-ottoman, pouf, pouffe, puff, hassock": 57.92458179756167, + "sem_seg/ACC-bottle": 24.32959878640393, + "sem_seg/ACC-buffet, counter, sideboard": 52.15165000875841, + "sem_seg/ACC-poster, posting, placard, notice, bill, card": 42.515699090093555, + "sem_seg/ACC-stage": 26.200310876336864, + "sem_seg/ACC-van": 56.97671631487864, + "sem_seg/ACC-ship": 91.4771498107085, + "sem_seg/ACC-fountain": 0.12776049166495876, + "sem_seg/ACC-conveyer belt, conveyor belt, conveyer, conveyor, transporter": 88.91585447191204, + "sem_seg/ACC-canopy": 38.449254267850726, + "sem_seg/ACC-washer, automatic washer, washing machine": 69.68135714164359, + "sem_seg/ACC-plaything, toy": 31.691064173126243, + "sem_seg/ACC-pool": 75.6815736498681, + "sem_seg/ACC-stool": 64.25863807344786, + "sem_seg/ACC-barrel, cask": 49.459551712367734, + "sem_seg/ACC-basket, handbasket": 25.51185334909116, + "sem_seg/ACC-falls": 75.49122009939157, + "sem_seg/ACC-tent": 98.87613276508756, + "sem_seg/ACC-bag": 14.225663846700288, + "sem_seg/ACC-minibike, motorbike": 86.67313203039816, + "sem_seg/ACC-cradle": 82.44952216713372, + "sem_seg/ACC-oven": 64.74011056976245, + "sem_seg/ACC-ball": 50.848473227553804, + "sem_seg/ACC-food, solid food": 75.23663370749071, + "sem_seg/ACC-step, stair": 23.333750773212504, + "sem_seg/ACC-tank, storage tank": 35.392898672344394, + "sem_seg/ACC-trade name": 35.23673865736949, + "sem_seg/ACC-microwave": 35.23264739136182, + "sem_seg/ACC-pot": 57.662740160530745, + "sem_seg/ACC-animal": 69.22888651841458, + "sem_seg/ACC-bicycle": 76.02842444851339, + "sem_seg/ACC-lake": 0.0, + "sem_seg/ACC-dishwasher": 73.81862784978065, + "sem_seg/ACC-screen": 68.82901062377589, + "sem_seg/ACC-blanket, cover": 24.200998881641556, + "sem_seg/ACC-sculpture": 54.817531393768725, + "sem_seg/ACC-hood, exhaust hood": 66.01869880758895, + "sem_seg/ACC-sconce": 48.20896111289604, + "sem_seg/ACC-vase": 58.810243817406004, + "sem_seg/ACC-traffic light": 46.8271679805943, + "sem_seg/ACC-tray": 19.54721302775101, + "sem_seg/ACC-trash can": 50.10424595426416, + "sem_seg/ACC-fan": 69.52375749629738, + "sem_seg/ACC-pier": 83.97367212612889, + "sem_seg/ACC-crt screen": 11.326224335348595, + "sem_seg/ACC-plate": 47.12998113966827, + "sem_seg/ACC-monitor": 12.943951408216492, + "sem_seg/ACC-bulletin board": 60.530070339611356, + "sem_seg/ACC-shower": 13.436955258570599, + "sem_seg/ACC-radiator": 48.24881571794673, + "sem_seg/ACC-glass, drinking glass": 19.87804394121771, + "sem_seg/ACC-clock": 39.78854542006448, + "sem_seg/ACC-flag": 35.83125422552486 + }, + "focoos_version": "0.15.0", + "latency": [ + { + "fps": 116, + "engine": "onnx.TensorrtExecutionProvider", + "min": 8.5, + "max": 8.675, + "mean": 8.554, + "std": 0.035, + "im_size": 640, + "device": "Tesla T4" + }, + { + "fps": 64, + "engine": "torchscript", + "min": 7.665, + "max": 15.928, + "mean": 15.477, + "std": 1.123, + "im_size": 640, + "device": "Tesla T4" + }, + { + "fps": 43, + "engine": "onnx.CUDAExecutionProvider", + "min": 22.266, + "max": 23.465, + "mean": 22.752, + "std": 0.294, + "im_size": 640, + "device": "Tesla T4" + } + ], + "updated_at": null +} diff --git a/focoos/model_registry/fai-mf-m-coco-ins.json b/focoos/model_registry/fai-mf-m-coco-ins.json new file mode 100644 index 00000000..7639b60e --- /dev/null +++ b/focoos/model_registry/fai-mf-m-coco-ins.json @@ -0,0 +1,273 @@ +{ + "name": "fai-mf-m-coco-ins", + "model_family": "fai_mf", + "classes": [ + "person", + "bicycle", + "car", + "motorcycle", + "airplane", + "bus", + "train", + "truck", + "boat", + "traffic light", + "fire hydrant", + "stop sign", + "parking meter", + "bench", + "bird", + "cat", + "dog", + "horse", + "sheep", + "cow", + "elephant", + "bear", + "zebra", + "giraffe", + "backpack", + "umbrella", + "handbag", + "tie", + "suitcase", + "frisbee", + "skis", + "snowboard", + "sports ball", + "kite", + "baseball bat", + "baseball glove", + "skateboard", + "surfboard", + "tennis racket", + "bottle", + "wine glass", + "cup", + "fork", + "knife", + "spoon", + "bowl", + "banana", + "apple", + "sandwich", + "orange", + "broccoli", + "carrot", + "hot dog", + "pizza", + "donut", + "cake", + "chair", + "couch", + "potted plant", + "bed", + "dining table", + "toilet", + "tv", + "laptop", + "mouse", + "remote", + "keyboard", + "cell phone", + "microwave", + "oven", + "toaster", + "sink", + "refrigerator", + "book", + "clock", + "vase", + "scissors", + "teddy bear", + "hair drier", + "toothbrush" + ], + "im_size": 1024, + "task": "instseg", + "config": { + "num_classes": 80, + "backbone_config": { + "use_pretrained": false, + "backbone_url": null, + "model_type": "resnet", + "in_chans": 3, + "depth": 101, + "variant": "d", + "freeze_at": -1, + "num_stages": 4, + "freeze_norm": false, + "act": "relu", + "pretrained": false + }, + "num_queries": 100, + "resolution": 1024, + "pixel_mean": [ + 123.675, + 116.28, + 103.53 + ], + "pixel_std": [ + 58.395, + 57.12, + 57.375 + ], + "size_divisibility": 0, + "pixel_decoder_out_dim": 128, + "pixel_decoder_feat_dim": 128, + "pixel_decoder_transformer_layers": 3, + "pixel_decoder_transformer_dropout": 0.0, + "pixel_decoder_transformer_nheads": 8, + "pixel_decoder_transformer_dim_feedforward": 1024, + "transformer_predictor_out_dim": 128, + "transformer_predictor_hidden_dim": 256, + "transformer_predictor_dec_layers": 6, + "transformer_predictor_dim_feedforward": 1024, + "head_out_dim": 128, + "cls_sigmoid": false, + "postprocessing_type": "instance", + "mask_threshold": 0.5, + "predict_all_pixels": false, + "use_mask_score": true, + "threshold": 0.5, + "top_k": 100, + "criterion_deep_supervision": true, + "criterion_eos_coef": 0.1, + "criterion_num_points": 12544, + "weight_dict_loss_dice": 5, + "weight_dict_loss_mask": 5, + "weight_dict_loss_ce": 2, + "matcher_cost_class": 2, + "matcher_cost_mask": 5, + "matcher_cost_dice": 5 + }, + "focoos_model": "fai-mf-m-coco-ins", + "ref": null, + "status": "TRAINING_COMPLETED", + "description": "MaskFormer medium model (COCO Instance Segmentation)", + "train_args": null, + "weights_uri": "https://public.focoos.ai/pretrained_models/fai-mf-m-coco-ins/model_final.pth", + "val_dataset": "coco_2017_instance", + "val_metrics": { + "segm/AP": 43.39762831623602, + "segm/AP50": 66.51922327141251, + "segm/AP75": 46.151995298597356, + "segm/APs": 23.348318683184445, + "segm/APm": 46.54232299693423, + "segm/APl": 63.458317072976634, + "segm/AP-person": 49.95852868752627, + "segm/AP-bicycle": 24.14581849770532, + "segm/AP-car": 44.35540219057337, + "segm/AP-motorcycle": 40.50950922797259, + "segm/AP-airplane": 56.16030789506462, + "segm/AP-bus": 69.29107580376869, + "segm/AP-train": 68.09374437696405, + "segm/AP-truck": 41.471557921342296, + "segm/AP-boat": 26.605714169973364, + "segm/AP-traffic light": 27.70057943332855, + "segm/AP-fire hydrant": 66.10747254120749, + "segm/AP-stop sign": 65.2593102406126, + "segm/AP-parking meter": 48.93953919874093, + "segm/AP-bench": 22.582884740407916, + "segm/AP-bird": 34.030837703372455, + "segm/AP-cat": 78.59383661301635, + "segm/AP-dog": 69.14784452636489, + "segm/AP-horse": 48.051410469507985, + "segm/AP-sheep": 53.219918258805954, + "segm/AP-cow": 53.861411088728914, + "segm/AP-elephant": 64.2854641646738, + "segm/AP-bear": 78.36456941429813, + "segm/AP-zebra": 63.4689011715302, + "segm/AP-giraffe": 61.49573825869585, + "segm/AP-backpack": 23.421814461615128, + "segm/AP-umbrella": 53.24777871255274, + "segm/AP-handbag": 22.379675928736148, + "segm/AP-tie": 35.680197747863986, + "segm/AP-suitcase": 45.045447953937035, + "segm/AP-frisbee": 67.03148500867991, + "segm/AP-skis": 9.0607214662226, + "segm/AP-snowboard": 28.87810807205354, + "segm/AP-sports ball": 46.05927732585104, + "segm/AP-kite": 32.84076408237384, + "segm/AP-baseball bat": 33.20036606189624, + "segm/AP-baseball glove": 44.942973758812485, + "segm/AP-skateboard": 43.57704610043122, + "segm/AP-surfboard": 40.075604749369646, + "segm/AP-tennis racket": 62.0044928015224, + "segm/AP-bottle": 39.86383279122771, + "segm/AP-wine glass": 38.67543559875645, + "segm/AP-cup": 49.4227792607431, + "segm/AP-fork": 25.61123277254682, + "segm/AP-knife": 19.36017385154991, + "segm/AP-spoon": 20.681560039985147, + "segm/AP-bowl": 44.24757563819151, + "segm/AP-banana": 20.698117456439075, + "segm/AP-apple": 21.314446441267524, + "segm/AP-sandwich": 43.00220737565878, + "segm/AP-orange": 31.757581411839304, + "segm/AP-broccoli": 23.71669746512037, + "segm/AP-carrot": 19.737306689547232, + "segm/AP-hot dog": 41.61659589754262, + "segm/AP-pizza": 54.52658583034326, + "segm/AP-donut": 51.98568380033627, + "segm/AP-cake": 42.93462261028003, + "segm/AP-chair": 25.309361443594252, + "segm/AP-couch": 45.45353599829125, + "segm/AP-potted plant": 27.417145529961413, + "segm/AP-bed": 44.72715347211512, + "segm/AP-dining table": 21.33000632625478, + "segm/AP-toilet": 65.96807423892646, + "segm/AP-tv": 64.07413060508233, + "segm/AP-laptop": 68.20284436150472, + "segm/AP-mouse": 61.87315699542837, + "segm/AP-remote": 38.13780753896421, + "segm/AP-keyboard": 52.767256372432826, + "segm/AP-cell phone": 40.38016941147302, + "segm/AP-microwave": 64.00280710169673, + "segm/AP-oven": 36.704915424733784, + "segm/AP-toaster": 54.91501382323853, + "segm/AP-sink": 40.158750304177126, + "segm/AP-refrigerator": 64.82704771688567, + "segm/AP-book": 12.548889490209742, + "segm/AP-clock": 52.6908681214101, + "segm/AP-vase": 40.63986691011187, + "segm/AP-scissors": 34.14835426134663, + "segm/AP-teddy bear": 51.82972408173971, + "segm/AP-hair drier": 8.08988045239655, + "segm/AP-toothbrush": 23.31393955943152 + }, + "focoos_version": "0.15.0", + "latency": [ + { + "fps": 61, + "engine": "onnx.TensorrtExecutionProvider", + "min": 16.139, + "max": 17.282, + "mean": 16.354, + "std": 0.242, + "im_size": 640, + "device": "Tesla T4" + }, + { + "fps": 21, + "engine": "torchscript", + "min": 44.949, + "max": 48.315, + "mean": 45.897, + "std": 0.617, + "im_size": 640, + "device": "Tesla T4" + }, + { + "fps": 16, + "engine": "onnx.CUDAExecutionProvider", + "min": 60.032, + "max": 63.466, + "mean": 61.229, + "std": 0.559, + "im_size": 640, + "device": "Tesla T4" + } + ], + "updated_at": null +} diff --git a/focoos/model_registry/fai-mf-s-coco-ins.json b/focoos/model_registry/fai-mf-s-coco-ins.json new file mode 100644 index 00000000..7a6d658c --- /dev/null +++ b/focoos/model_registry/fai-mf-s-coco-ins.json @@ -0,0 +1,273 @@ +{ + "name": "fai-mf-s-coco-ins", + "model_family": "fai_mf", + "classes": [ + "person", + "bicycle", + "car", + "motorcycle", + "airplane", + "bus", + "train", + "truck", + "boat", + "traffic light", + "fire hydrant", + "stop sign", + "parking meter", + "bench", + "bird", + "cat", + "dog", + "horse", + "sheep", + "cow", + "elephant", + "bear", + "zebra", + "giraffe", + "backpack", + "umbrella", + "handbag", + "tie", + "suitcase", + "frisbee", + "skis", + "snowboard", + "sports ball", + "kite", + "baseball bat", + "baseball glove", + "skateboard", + "surfboard", + "tennis racket", + "bottle", + "wine glass", + "cup", + "fork", + "knife", + "spoon", + "bowl", + "banana", + "apple", + "sandwich", + "orange", + "broccoli", + "carrot", + "hot dog", + "pizza", + "donut", + "cake", + "chair", + "couch", + "potted plant", + "bed", + "dining table", + "toilet", + "tv", + "laptop", + "mouse", + "remote", + "keyboard", + "cell phone", + "microwave", + "oven", + "toaster", + "sink", + "refrigerator", + "book", + "clock", + "vase", + "scissors", + "teddy bear", + "hair drier", + "toothbrush" + ], + "im_size": 1024, + "task": "instseg", + "config": { + "num_classes": 80, + "backbone_config": { + "use_pretrained": false, + "backbone_url": null, + "model_type": "resnet", + "in_chans": 3, + "depth": 50, + "variant": "d", + "freeze_at": -1, + "num_stages": 4, + "freeze_norm": false, + "act": "relu", + "pretrained": false + }, + "num_queries": 100, + "resolution": 1024, + "pixel_mean": [ + 123.675, + 116.28, + 103.53 + ], + "pixel_std": [ + 58.395, + 57.12, + 57.375 + ], + "size_divisibility": 0, + "pixel_decoder_out_dim": 128, + "pixel_decoder_feat_dim": 128, + "pixel_decoder_transformer_layers": 3, + "pixel_decoder_transformer_dropout": 0.0, + "pixel_decoder_transformer_nheads": 8, + "pixel_decoder_transformer_dim_feedforward": 1024, + "transformer_predictor_out_dim": 128, + "transformer_predictor_hidden_dim": 256, + "transformer_predictor_dec_layers": 6, + "transformer_predictor_dim_feedforward": 1024, + "head_out_dim": 128, + "cls_sigmoid": false, + "postprocessing_type": "instance", + "mask_threshold": 0.5, + "predict_all_pixels": false, + "use_mask_score": true, + "threshold": 0.5, + "top_k": 100, + "criterion_deep_supervision": true, + "criterion_eos_coef": 0.1, + "criterion_num_points": 12544, + "weight_dict_loss_dice": 5, + "weight_dict_loss_mask": 5, + "weight_dict_loss_ce": 2, + "matcher_cost_class": 2, + "matcher_cost_mask": 5, + "matcher_cost_dice": 5 + }, + "focoos_model": "fai-mf-s-coco-ins", + "ref": null, + "status": "TRAINING_COMPLETED", + "description": "MaskFormer small model (COCO Instance Segmentation)", + "train_args": null, + "weights_uri": "https://public.focoos.ai/pretrained_models/fai-mf-s-coco-ins/model_final.pth", + "val_dataset": "coco_2017_instance", + "val_metrics": { + "segm/AP": 39.04339585798063, + "segm/AP50": 61.676628632156806, + "segm/AP75": 40.861016469019994, + "segm/APs": 16.482791526128768, + "segm/APm": 42.02909754985479, + "segm/APl": 62.52866481050838, + "segm/AP-person": 43.79427297224655, + "segm/AP-bicycle": 18.606668713910906, + "segm/AP-car": 35.28138958594263, + "segm/AP-motorcycle": 36.63515495580118, + "segm/AP-airplane": 50.79643523740094, + "segm/AP-bus": 65.78796722060156, + "segm/AP-train": 66.9207140998984, + "segm/AP-truck": 36.680235290602944, + "segm/AP-boat": 23.926769126556934, + "segm/AP-traffic light": 22.864855534724345, + "segm/AP-fire hydrant": 66.85719409412239, + "segm/AP-stop sign": 60.88031012648182, + "segm/AP-parking meter": 48.08248895410875, + "segm/AP-bench": 19.714022759543955, + "segm/AP-bird": 29.217854273266642, + "segm/AP-cat": 75.49286481398212, + "segm/AP-dog": 66.09914723792697, + "segm/AP-horse": 45.1998950768167, + "segm/AP-sheep": 48.518597236043455, + "segm/AP-cow": 45.68101160496066, + "segm/AP-elephant": 61.514614325603, + "segm/AP-bear": 78.91952026435156, + "segm/AP-zebra": 61.4462494371019, + "segm/AP-giraffe": 57.107699839345514, + "segm/AP-backpack": 17.787362213640645, + "segm/AP-umbrella": 48.501951370079574, + "segm/AP-handbag": 17.878876837935703, + "segm/AP-tie": 27.83538613082725, + "segm/AP-suitcase": 40.547354140190286, + "segm/AP-frisbee": 61.38542676134695, + "segm/AP-skis": 5.245939921142663, + "segm/AP-snowboard": 22.204490552599708, + "segm/AP-sports ball": 37.350844867953676, + "segm/AP-kite": 28.719690792376078, + "segm/AP-baseball bat": 25.35444239730437, + "segm/AP-baseball glove": 38.5276321071945, + "segm/AP-skateboard": 33.64651442284908, + "segm/AP-surfboard": 32.95040665609137, + "segm/AP-tennis racket": 53.66966549928027, + "segm/AP-bottle": 32.83950044984472, + "segm/AP-wine glass": 32.21110163167387, + "segm/AP-cup": 40.7544522235141, + "segm/AP-fork": 17.873912579142317, + "segm/AP-knife": 14.77737647586626, + "segm/AP-spoon": 12.891158319166765, + "segm/AP-bowl": 40.44984159816354, + "segm/AP-banana": 24.137432295958632, + "segm/AP-apple": 21.03338397316318, + "segm/AP-sandwich": 42.025797215467335, + "segm/AP-orange": 30.373644265739575, + "segm/AP-broccoli": 22.658576973960898, + "segm/AP-carrot": 20.68983126539135, + "segm/AP-hot dog": 35.7920599195588, + "segm/AP-pizza": 53.41129034658181, + "segm/AP-donut": 48.96289783412177, + "segm/AP-cake": 42.00251892167526, + "segm/AP-chair": 21.365622947892863, + "segm/AP-couch": 41.17376939190738, + "segm/AP-potted plant": 21.622443077042053, + "segm/AP-bed": 43.54723149266776, + "segm/AP-dining table": 20.078150881790545, + "segm/AP-toilet": 65.47837327110923, + "segm/AP-tv": 60.489324195255044, + "segm/AP-laptop": 63.56193048192741, + "segm/AP-mouse": 57.29866152128699, + "segm/AP-remote": 29.523953658514955, + "segm/AP-keyboard": 50.54361456798821, + "segm/AP-cell phone": 35.82497172233372, + "segm/AP-microwave": 57.4299201423982, + "segm/AP-oven": 32.253276924675845, + "segm/AP-toaster": 44.16560584629892, + "segm/AP-sink": 37.147662073659376, + "segm/AP-refrigerator": 62.657412701703066, + "segm/AP-book": 8.801748872814999, + "segm/AP-clock": 51.49023189799791, + "segm/AP-vase": 33.507824941902555, + "segm/AP-scissors": 22.663626940006353, + "segm/AP-teddy bear": 47.2621645478268, + "segm/AP-hair drier": 9.421984985572811, + "segm/AP-toothbrush": 15.645467812732806 + }, + "focoos_version": "0.15.0", + "latency": [ + { + "fps": 78, + "engine": "onnx.TensorrtExecutionProvider", + "min": 12.714, + "max": 12.905, + "mean": 12.806, + "std": 0.046, + "im_size": 640, + "device": "Tesla T4" + }, + { + "fps": 30, + "engine": "torchscript", + "min": 32.081, + "max": 35.454, + "mean": 32.765, + "std": 0.535, + "im_size": 640, + "device": "Tesla T4" + }, + { + "fps": 21, + "engine": "onnx.CUDAExecutionProvider", + "min": 45.868, + "max": 47.933, + "mean": 46.499, + "std": 0.382, + "im_size": 640, + "device": "Tesla T4" + } + ], + "updated_at": null +} diff --git a/focoos/model_registry/model_registry.py b/focoos/model_registry/model_registry.py new file mode 100644 index 00000000..595fa12e --- /dev/null +++ b/focoos/model_registry/model_registry.py @@ -0,0 +1,78 @@ +import os +from typing import Dict + +from focoos.ports import ModelInfo +from focoos.utils.logger import get_logger + +logger = get_logger(__name__) + +REGISTRY_PATH = os.path.dirname(__file__) + + +class ModelRegistry: + """Central registry of pretrained models. + + This class serves as a centralized registry for all pretrained models in the Focoos system. + It provides methods to access model information, list available models, and check model existence. + + Attributes: + _pretrained_models (Dict[str, str]): Dictionary mapping model names to their JSON file paths. + """ + + _pretrained_models: Dict[str, str] = { + "fai-detr-l-obj365": os.path.join(REGISTRY_PATH, "fai-detr-l-obj365.json"), + "fai-detr-l-coco": os.path.join(REGISTRY_PATH, "fai-detr-l-coco.json"), + "fai-detr-m-coco": os.path.join(REGISTRY_PATH, "fai-detr-m-coco.json"), + "fai-mf-l-ade": os.path.join(REGISTRY_PATH, "fai-mf-l-ade.json"), + "fai-mf-m-ade": os.path.join(REGISTRY_PATH, "fai-mf-m-ade.json"), + "fai-mf-l-coco-ins": os.path.join(REGISTRY_PATH, "fai-mf-l-coco-ins.json"), + "fai-mf-m-coco-ins": os.path.join(REGISTRY_PATH, "fai-mf-m-coco-ins.json"), + "fai-mf-s-coco-ins": os.path.join(REGISTRY_PATH, "fai-mf-s-coco-ins.json"), + "bisenetformer-m-ade": os.path.join(REGISTRY_PATH, "bisenetformer-m-ade.json"), + "bisenetformer-l-ade": os.path.join(REGISTRY_PATH, "bisenetformer-l-ade.json"), + "bisenetformer-s-ade": os.path.join(REGISTRY_PATH, "bisenetformer-s-ade.json"), + } + + @classmethod + def get_model_info(cls, model_name: str) -> ModelInfo: + """Get the model information for a given model name. + + Args: + model_name (str): The name of the model to retrieve information for. + Can be either a pretrained model name or a path to a JSON file. + + Returns: + ModelInfo: The model information object containing model details. + + Raises: + ValueError: If the model is not found in the registry and the provided + path does not exist. + """ + if model_name in cls._pretrained_models: + return ModelInfo.from_json(cls._pretrained_models[model_name]) + if not os.path.exists(model_name): + logger.warning(f"โš ๏ธ Model {model_name} not found") + raise ValueError(f"โš ๏ธ Model {model_name} not found") + return ModelInfo.from_json(model_name) + + @classmethod + def list_models(cls) -> list[str]: + """List all available pretrained models. + + Returns: + list[str]: A list of all available pretrained model names. + """ + return list(cls._pretrained_models.keys()) + + @classmethod + def exists(cls, model_name: str) -> bool: + """Check if a model exists in the registry. + + Args: + model_name (str): The name of the model to check. + + Returns: + bool: True if the model exists in the pretrained models registry, + False otherwise. + """ + return model_name in cls._pretrained_models diff --git a/focoos/models/__init__.py b/focoos/models/__init__.py new file mode 100644 index 00000000..f27ef591 --- /dev/null +++ b/focoos/models/__init__.py @@ -0,0 +1,5 @@ +from focoos.models.base_model import BaseModelNN +from focoos.models.focoos_model import FocoosModel +from focoos.processor.base_processor import Processor + +__all__ = ["Processor", "BaseModelNN", "FocoosModel"] diff --git a/focoos/models/base_model.py b/focoos/models/base_model.py new file mode 100644 index 00000000..90e68243 --- /dev/null +++ b/focoos/models/base_model.py @@ -0,0 +1,196 @@ +from abc import ABC, abstractmethod +from typing import Tuple, Union + +import numpy as np +import torch +from PIL import Image +from torch import nn + +from focoos.ports import DatasetEntry, LatencyMetrics, ModelConfig, ModelOutput +from focoos.utils.checkpoint import IncompatibleKeys, strip_prefix_if_present +from focoos.utils.logger import get_logger + +logger = get_logger("BaseModelNN") + + +class BaseModelNN(ABC, nn.Module): + """Abstract base class for neural network models in Focoos. + + This class provides a common interface for all neural network models, + defining abstract methods that must be implemented by concrete model classes. + It extends both ABC (Abstract Base Class) and nn.Module from PyTorch. + + Args: + config: Model configuration containing hyperparameters and settings. + """ + + def __init__(self, config: ModelConfig): + """Initialize the base model. + + Args: + config: Model configuration object containing model parameters + and settings. + """ + super().__init__() + + @property + @abstractmethod + def device(self) -> torch.device: + """Get the device where the model is located. + + Returns: + The PyTorch device (CPU or CUDA) where the model parameters + are stored. + + Raises: + NotImplementedError: This method must be implemented by subclasses. + """ + raise NotImplementedError("Device is not implemented for this model.") + + @property + @abstractmethod + def dtype(self) -> torch.dtype: + """Get the data type of the model parameters. + + Returns: + The PyTorch data type (e.g., float32, float16) of the model + parameters. + + Raises: + NotImplementedError: This method must be implemented by subclasses. + """ + raise NotImplementedError("Dtype is not implemented for this model.") + + @abstractmethod + def forward( + self, + inputs: Union[ + torch.Tensor, + np.ndarray, + Image.Image, + list[Image.Image], + list[np.ndarray], + list[torch.Tensor], + list[DatasetEntry], + ], + ) -> ModelOutput: + """Perform forward pass through the model. + + Args: + inputs: Input data in various supported formats: + - torch.Tensor: Single tensor input + - np.ndarray: Single numpy array input + - Image.Image: Single PIL Image input + - list[Image.Image]: List of PIL Images + - list[np.ndarray]: List of numpy arrays + - list[torch.Tensor]: List of tensors + - list[DatasetEntry]: List of dataset entries + + Returns: + Model output containing predictions and any additional metadata. + + Raises: + NotImplementedError: This method must be implemented by subclasses. + """ + raise NotImplementedError("Forward is not implemented for this model.") + + def load_state_dict(self, checkpoint_state_dict: dict, strict: bool = True) -> IncompatibleKeys: + """Load model state dictionary from checkpoint with preprocessing. + + This method handles common issues when loading checkpoints: + - Removes "module." prefix from DataParallel/DistributedDataParallel models + - Handles shape mismatches by removing incompatible parameters + - Logs incompatible keys for debugging + + Args: + checkpoint_state_dict: Dictionary containing model parameters from + a saved checkpoint. + strict: Whether to strictly enforce that the keys in checkpoint_state_dict + match the keys returned by this module's state_dict() function. + Defaults to True. + + Returns: + IncompatibleKeys object containing information about missing keys, + unexpected keys, and parameters with incorrect shapes. + """ + # if the state_dict comes from a model that was wrapped in a + # DataParallel or DistributedDataParallel during serialization, + # remove the "module" prefix before performing the matching. + strip_prefix_if_present(checkpoint_state_dict, "module.") + + # workaround https://github.com/pytorch/pytorch/issues/24139 + model_state_dict = self.state_dict() + incorrect_shapes = [] + for k in list(checkpoint_state_dict.keys()): + if k in model_state_dict: + model_param = model_state_dict[k] + shape_model = tuple(model_param.shape) + shape_checkpoint = tuple(checkpoint_state_dict[k].shape) + if shape_model != shape_checkpoint: + incorrect_shapes.append((k, shape_checkpoint, shape_model)) + checkpoint_state_dict.pop(k) + + incompatible = super().load_state_dict(checkpoint_state_dict, strict=strict) + incompatible = IncompatibleKeys( + missing_keys=incompatible.missing_keys, + unexpected_keys=incompatible.unexpected_keys, + incorrect_shapes=incorrect_shapes, + ) + + incompatible.log_incompatible_keys() + + return incompatible + + def benchmark(self, iterations: int = 50, size: Tuple[int, int] = (640, 640)) -> LatencyMetrics: + """Benchmark model inference latency and throughput. + + Performs multiple inference runs on random data to measure model + performance metrics including FPS, mean latency, and latency statistics. + Uses CUDA events for precise timing when running on GPU. + + Args: + iterations: Number of inference runs to perform for benchmarking. + Defaults to 50. + size: Input image size as (height, width) tuple. Defaults to (640, 640). + + Returns: + LatencyMetrics object containing: + - fps: Frames per second (throughput) + - engine: Hardware/framework used for inference + - mean: Mean inference time in milliseconds + - max: Maximum inference time in milliseconds + - min: Minimum inference time in milliseconds + - std: Standard deviation of inference times + - im_size: Input image size + - device: Device used for inference + + Note: + This method assumes the model is running on CUDA for timing. + Input data is randomly generated for benchmarking purposes. + """ + logger.info(f"โฑ๏ธ Benchmarking latency on {self.device}, size: {size}x{size}..") + # warmup + data = 128 * torch.randn(1, 3, size[0], size[1]).to(self.device) + durations = [] + for _ in range(iterations): + start = torch.cuda.Event(enable_timing=True) + end = torch.cuda.Event(enable_timing=True) + start.record(stream=torch.cuda.Stream()) + _ = self(data) + end.record(stream=torch.cuda.Stream()) + torch.cuda.synchronize() + durations.append(start.elapsed_time(end)) + + durations = np.array(durations) + metrics = LatencyMetrics( + fps=int(1000 / durations.mean()), + engine=f"torch.{self.device}", + mean=round(durations.mean().astype(float), 3), + max=round(durations.max().astype(float), 3), + min=round(durations.min().astype(float), 3), + std=round(durations.std().astype(float), 3), + im_size=size[0], # FIXME: this is a hack to get the im_size as int, assuming it's a square + device=str(self.device), + ) + logger.info(f"๐Ÿ”ฅ FPS: {metrics.fps} Mean latency: {metrics.mean} ms ") + return metrics diff --git a/focoos/models/bisenetformer/__init__.py b/focoos/models/bisenetformer/__init__.py new file mode 100644 index 00000000..7823a378 --- /dev/null +++ b/focoos/models/bisenetformer/__init__.py @@ -0,0 +1,25 @@ +def _register(): + from focoos.model_manager import ConfigManager, ModelManager + from focoos.ports import ModelFamily + from focoos.processor import ProcessorManager + + def load_model(): + # Questa importazione avviene SOLO quando load_rtdetr_model viene chiamata + from focoos.models.bisenetformer.modelling import BisenetFormer + + return BisenetFormer + + def load_config(): + from focoos.models.bisenetformer.config import BisenetFormerConfig + + return BisenetFormerConfig + + def load_processor(): + from focoos.models.bisenetformer.processor import BisenetFormerProcessor + + return BisenetFormerProcessor + + # Qui registriamo solo la funzione load_rtdetr_model, NON viene eseguita + ModelManager.register_model(ModelFamily.BISENETFORMER, load_model) + ConfigManager.register_config(ModelFamily.BISENETFORMER, load_config) + ProcessorManager.register_processor(ModelFamily.BISENETFORMER, load_processor) diff --git a/focoos/models/bisenetformer/config.py b/focoos/models/bisenetformer/config.py new file mode 100644 index 00000000..5b9b84cd --- /dev/null +++ b/focoos/models/bisenetformer/config.py @@ -0,0 +1,55 @@ +from dataclasses import dataclass, field +from typing import List, Literal + +from focoos.nn.backbone.base import BackboneConfig +from focoos.ports import ModelConfig + +PostprocessingType = Literal["semantic", "instance"] + + +@dataclass +class BisenetFormerConfig(ModelConfig): + backbone_config: BackboneConfig + num_classes: int + + num_queries: int = 100 + + # Image detector configuration + pixel_mean: List[float] = field(default_factory=lambda: [123.675, 116.28, 103.53]) + pixel_std: List[float] = field(default_factory=lambda: [58.395, 57.12, 57.375]) + size_divisibility: int = 0 + + # Sizing configuration + pixel_decoder_out_dim: int = 256 + pixel_decoder_feat_dim: int = 256 + + # Transformer decoder + transformer_predictor_out_dim: int = 256 + transformer_predictor_hidden_dim: int = 256 + transformer_predictor_dec_layers: int = 6 + transformer_predictor_dim_feedforward: int = 1024 + # Head configuration + head_out_dim: int = 256 + cls_sigmoid: bool = False + + # Inference configuration + # Options: "semantic", "instance", "panoptic" + postprocessing_type: PostprocessingType = "semantic" + top_k: int = 300 + mask_threshold: float = 0.5 + predict_all_pixels: bool = False + use_mask_score: bool = False + threshold: float = 0.5 + + # Loss configuration + criterion_deep_supervision: bool = True + criterion_eos_coef: float = 0.1 + criterion_num_points: int = 12544 + + weight_dict_loss_dice: int = 5 + weight_dict_loss_mask: int = 5 + weight_dict_loss_ce: int = 2 + + matcher_cost_class: int = 2 + matcher_cost_mask: int = 5 + matcher_cost_dice: int = 5 diff --git a/focoos/models/bisenetformer/loss.py b/focoos/models/bisenetformer/loss.py new file mode 100644 index 00000000..e229383b --- /dev/null +++ b/focoos/models/bisenetformer/loss.py @@ -0,0 +1,756 @@ +# Copyright (c) FocoosAI SRL. All rights reserved. +import warnings +from typing import List, Optional + +import torch +import torch.nn.functional as F +import torchvision +from scipy.optimize import linear_sum_assignment +from torch import Tensor, autocast, nn + +from focoos.models.bisenetformer.ports import BisenetFormerTargets +from focoos.nn.layers.point_rend import get_uncertain_point_coords_with_randomness, point_sample +from focoos.utils.distributed.comm import get_world_size +from focoos.utils.distributed.dist import is_dist_available_and_initialized + +""" +Shape shorthand in this module: + + N: minibatch dimension size, i.e. the number of RoIs for instance segmenation or the + number of images for semantic segmenation. + R: number of ROIs, combined over all images, in the minibatch + P: number of points +""" + + +def calculate_uncertainty(logits): + """ + We estimate uncerainty as L1 distance between 0.0 and the logit prediction in 'logits' for the + foreground class in `classes`. THIS IS IMPLICLTY BASED ON SIGMOID ACTIVATION! + Args: + logits (Tensor): A tensor of shape (R, 1, ...) for class-specific or + class-agnostic, where R is the total number of predicted masks in all images and C is + the number of foreground classes. The values are logits. + Returns: + scores (Tensor): A tensor of shape (R, 1, ...) that contains uncertainty scores with + the most uncertain locations having the highest uncertainty score. + """ + assert logits.shape[1] == 1 + gt_class_logits = logits.clone() + return -(torch.abs(gt_class_logits)) + + +def _max_by_axis(the_list): + # type: (List[List[int]]) -> List[int] + maxes = the_list[0] + for sublist in the_list[1:]: + for index, item in enumerate(sublist): + maxes[index] = max(maxes[index], item) + return maxes + + +class NestedTensor: + def __init__(self, tensors, mask: Optional[Tensor]): + self.tensors = tensors + self.mask = mask + + def to(self, device): + cast_tensor = self.tensors.to(device) + mask = self.mask + if mask is not None: + assert mask is not None + cast_mask = mask.to(device) + else: + cast_mask = None + return NestedTensor(cast_tensor, cast_mask) + + def decompose(self): + return self.tensors, self.mask + + def __repr__(self): + return str(self.tensors) + + +def nested_tensor_from_tensor_list(tensor_list: List[Tensor]): + # TODO make this more general + if tensor_list[0].ndim == 3: + if torchvision._is_tracing(): + # nested_tensor_from_tensor_list() does not export well to ONNX + # call _onnx_nested_tensor_from_tensor_list() instead + return _onnx_nested_tensor_from_tensor_list(tensor_list) + + # TODO make it support different-sized images + max_size = _max_by_axis([list(img.shape) for img in tensor_list]) + # min_size = tuple(min(s) for s in zip(*[img.shape for img in tensor_list])) + batch_shape = [len(tensor_list)] + max_size + b, c, h, w = batch_shape + dtype = tensor_list[0].dtype + device = tensor_list[0].device + tensor = torch.zeros(batch_shape, dtype=dtype, device=device) + mask = torch.ones((b, h, w), dtype=torch.bool, device=device) + for img, pad_img, m in zip(tensor_list, tensor, mask): + pad_img[: img.shape[0], : img.shape[1], : img.shape[2]].copy_(img) + m[: img.shape[1], : img.shape[2]] = False + else: + raise ValueError("not supported") + return NestedTensor(tensor, mask) + + +# _onnx_nested_tensor_from_tensor_list() is an implementation of +# nested_tensor_from_tensor_list() that is supported by ONNX tracing. +@torch.jit.unused +def _onnx_nested_tensor_from_tensor_list(tensor_list: List[Tensor]) -> NestedTensor: + max_size = [] + for i in range(tensor_list[0].dim()): + max_size_i = torch.max(torch.stack([img.shape[i] for img in tensor_list]).to(torch.float32)).to(torch.int64) + max_size.append(max_size_i) + max_size = tuple(max_size) + + # work around for + # pad_img[: img.shape[0], : img.shape[1], : img.shape[2]].copy_(img) + # m[: img.shape[1], :img.shape[2]] = False + # which is not yet supported in onnx + padded_imgs = [] + padded_masks = [] + for img in tensor_list: + padding = [(s1 - s2) for s1, s2 in zip(max_size, tuple(img.shape))] + padded_img = torch.nn.functional.pad(img, (0, padding[2], 0, padding[1], 0, padding[0])) + padded_imgs.append(padded_img) + + m = torch.zeros_like(img[0], dtype=torch.int, device=img.device) + padded_mask = torch.nn.functional.pad(m, (0, padding[2], 0, padding[1]), "constant", 1) + padded_masks.append(padded_mask.to(torch.bool)) + + tensor = torch.stack(padded_imgs) + mask = torch.stack(padded_masks) + + return NestedTensor(tensor, mask=mask) + + +def softmax_dice_loss( + inputs: torch.Tensor, + targets: torch.Tensor, + num_masks: float, +): + """ + Compute the DICE loss, similar to generalized IOU for masks + Args: + inputs: A float tensor of arbitrary shape. + The predictions for each example. + targets: A float tensor with the same shape as inputs. Stores the binary + classification label for each element in inputs + (0 for the negative class and 1 for the positive class). + """ + inputs = inputs.flatten(1) + numerator = 2 * (inputs * targets).sum(-1) + 1.0 + denominator = inputs.sum(-1) + targets.sum(-1) + 1.0 + loss = 1 - (numerator + 1) / (denominator + 1) + return loss.sum() / num_masks + + +def softmax_ce_loss( + inputs: torch.Tensor, + targets: torch.Tensor, + num_masks: float, +): + # loss = F.binary_cross_entropy_with_logits(inputs, targets, reduction="none") + loss = -inputs * targets + + return loss.mean(1).sum() / num_masks + + +softmax_dice_loss_jit = torch.jit.script(softmax_dice_loss) # type: torch.jit.ScriptModule + +softmax_ce_loss_jit = torch.jit.script(softmax_ce_loss) # type: torch.jit.ScriptModule + + +def dice_loss( + inputs: torch.Tensor, + targets: torch.Tensor, + num_masks: float, +): + """ + Compute the DICE loss, similar to generalized IOU for masks + Args: + inputs: A float tensor of arbitrary shape. + The predictions for each example. + targets: A float tensor with the same shape as inputs. Stores the binary + classification label for each element in inputs + (0 for the negative class and 1 for the positive class). + """ + inputs = inputs.sigmoid() + inputs = inputs.flatten(1) + numerator = 2 * (inputs * targets).sum(-1) + denominator = inputs.sum(-1) + targets.sum(-1) + loss = 1 - (numerator + 1) / (denominator + 1) + return loss.sum() / num_masks + + +dice_loss_jit = torch.jit.script(dice_loss) # type: torch.jit.ScriptModule + + +def sigmoid_ce_loss( + inputs: torch.Tensor, + targets: torch.Tensor, + num_masks: float, +): + """ + Args: + inputs: A float tensor of arbitrary shape. + The predictions for each example. + targets: A float tensor with the same shape as inputs. Stores the binary + classification label for each element in inputs + (0 for the negative class and 1 for the positive class). + Returns: + Loss tensor + """ + loss = F.binary_cross_entropy_with_logits(inputs, targets, reduction="none") + + return loss.mean(1).sum() / num_masks + + +sigmoid_ce_loss_jit = torch.jit.script(sigmoid_ce_loss) # type: torch.jit.ScriptModule + + +def focal_loss( + inputs: torch.Tensor, + targets: torch.Tensor, + alpha: float = 10, + gamma: float = 2, + reduction: str = "mean", + ignore_index: int = 255, +) -> torch.Tensor: + """ + Loss used in RetinaNet for dense detection: https://arxiv.org/abs/1708.02002. + Args: + inputs: A float tensor of arbitrary shape. + The predictions for each example. + targets: A float tensor with the same shape as inputs. Stores the binary + classification label for each element in inputs + (0 for the negative class and 1 for the positive class). + alpha: (optional) Weighting factor in range (0,1) to balance + positive vs negative examples. Default = -1 (no weighting). + gamma: Exponent of the modulating factor (1 - p_t) to + balance easy vs hard examples. + reduction: 'none' | 'mean' | 'sum' + 'none': No reduction will be applied to the output. + 'mean': The output will be averaged. + 'sum': The output will be summed. + ignore_index: + Returns: + Loss tensor with the reduction option applied. + """ + ce_loss = F.cross_entropy(inputs, targets, reduction="none", ignore_index=ignore_index) + p_t = torch.exp(-ce_loss) + loss = ce_loss * ((1 - p_t) ** gamma) + + if alpha >= 0: + loss = alpha * loss + + if reduction == "mean": + loss = loss.mean() + elif reduction == "sum": + loss = loss.sum() + + return loss + + +focal_loss_jit = torch.jit.script(focal_loss) # type: torch.jit.ScriptModule + + +def batch_dice_loss(inputs: torch.Tensor, targets: torch.Tensor): + """ + Compute the DICE loss, similar to generalized IOU for masks + Args: + inputs: A float tensor of arbitrary shape. + The predictions for each example. + targets: A float tensor with the same shape as inputs. Stores the binary + classification label for each element in inputs + (0 for the negative class and 1 for the positive class). + """ + inputs = inputs.sigmoid() + inputs = inputs.flatten(1) + numerator = 2 * torch.einsum("nc,mc->nm", inputs, targets) + denominator = inputs.sum(-1)[:, None] + targets.sum(-1)[None, :] + loss = 1 - (numerator + 1) / (denominator + 1) + return loss + + +batch_dice_loss_jit = torch.jit.script(batch_dice_loss) # type: torch.jit.ScriptModule + + +def batch_sigmoid_ce_loss(inputs: torch.Tensor, targets: torch.Tensor): + """ + Args: + inputs: A float tensor of arbitrary shape. + The predictions for each example. + targets: A float tensor with the same shape as inputs. Stores the binary + classification label for each element in inputs + (0 for the negative class and 1 for the positive class). + Returns: + Loss tensor + """ + hw = inputs.shape[1] + + pos = F.binary_cross_entropy_with_logits(inputs, torch.ones_like(inputs), reduction="none") + neg = F.binary_cross_entropy_with_logits(inputs, torch.zeros_like(inputs), reduction="none") + + loss = torch.einsum("nc,mc->nm", pos, targets) + torch.einsum("nc,mc->nm", neg, (1 - targets)) + + return loss / hw + + +batch_sigmoid_ce_loss_jit = torch.jit.script(batch_sigmoid_ce_loss) # type: torch.jit.ScriptModule + + +def batch_soft_dice_loss(inputs: torch.Tensor, targets: torch.Tensor): + """ + Compute the DICE loss, similar to generalized IOU for masks + Args: + inputs: A float tensor of arbitrary shape. + The predictions for each example. + targets: A float tensor with the same shape as inputs. Stores the binary + classification label for each element in inputs + (0 for the negative class and 1 for the positive class). + """ + inputs = inputs.flatten(1) + numerator = 2 * torch.einsum("nc,mc->nm", inputs, targets) + denominator = inputs.sum(-1)[:, None] + targets.sum(-1)[None, :] + loss = (numerator + 1) / (denominator + 1) + return loss + + +batch_soft_dice_loss_jit = torch.jit.script(batch_soft_dice_loss) # type: torch.jit.ScriptModule + + +@torch.no_grad() +def accuracy(output, target, topk=(1,)): + """Computes the precision@k for the specified values of k""" + if target.numel() == 0: + return [torch.zeros([], device=output.device)] + maxk = max(topk) + batch_size = target.size(0) + + _, pred = output.topk(maxk, 1, True, True) + pred = pred.t() + correct = pred.eq(target.view(1, -1).expand_as(pred)) + + res = [] + for k in topk: + correct_k = correct[:k].view(-1).float().sum(0) + res.append(correct_k.mul_(1.0 / batch_size)) + return res + + +class SetCriterion(nn.Module): + """This class computes the loss for DETR. + The process happens in two steps: + 1) we compute hungarian assignment between ground truth boxes and the outputs of the model + 2) we supervise each pair of matched ground-truth / prediction (supervise class and box) + """ + + def __init__( + self, + num_classes: int, + matcher: nn.Module, + weight_dict: dict, + losses: list[str], + eos_coef: float = 0.1, + num_points: int = 0, + oversample_ratio: float = 3.0, + importance_sample_ratio: float = 0.0, + deep_supervision: bool = True, + use_focal: bool = False, + loss_class_type: str = "ce_loss", # ce_loss, focal_loss, bce_loss, bce_focal_loss + focal_alpha: float = 0.75, + focal_gamma: float = 2.0, + cls_sigmoid: bool = False, + ): + """Create the criterion. + Parameters: + num_classes: number of object categories, omitting the special no-object category + matcher: module able to compute a matching between targets and proposals + weight_dict: dict containing as key the names of the losses and as values their relative weight. + eos_coef: relative classification weight applied to the no-object category + losses: list of all the losses to be applied. See get_loss for list of available losses. + """ + super().__init__() + assert loss_class_type in [ + "ce_loss", + "focal_loss", + "bce_loss", + "bce_focal_loss", + ], "loss_class_type must be in ['ce_loss', 'focal_loss', 'bce_loss', 'bce_focal_loss']" + + self.num_classes = num_classes + self.matcher = matcher + self.weight_dict = weight_dict + self.losses = losses + self.deep_supervision = deep_supervision + + self.eos_coef = eos_coef + empty_weight = torch.ones(self.num_classes + 1) + empty_weight[-1] = self.eos_coef + self.register_buffer("empty_weight", empty_weight) + + # pointwise mask loss parameters + self.num_points = num_points + self.oversample_ratio = oversample_ratio + self.importance_sample_ratio = importance_sample_ratio + self.use_focal = use_focal + if use_focal: + warnings.warn( + "use_focal is deprecated. Use loss_class_type instead", + DeprecationWarning, + ) + loss_class_type = "focal_loss" if loss_class_type == "ce_loss" else loss_class_type + loss_class_type = "bce_focal_loss" if loss_class_type == "bce_loss" else loss_class_type + self.loss_class_type = loss_class_type + self.focal_alpha = focal_alpha + self.focal_gamma = focal_gamma + self.cls_sigmoid = cls_sigmoid + + def loss_labels(self, outputs, targets: list[BisenetFormerTargets], indices, num_masks, log=False): + """Classification loss (NLL) + targets dicts must contain the key "labels" containing a tensor of dim [nb_target_boxes] + """ + assert "pred_logits" in outputs + src_logits = outputs["pred_logits"].float() + + idx = self._get_src_permutation_idx(indices) + target_classes_o = torch.cat([t.labels[J] for t, (_, J) in zip(targets, indices)]) + target_classes = torch.full( + src_logits.shape[:2], + self.num_classes, + dtype=torch.int64, + device=src_logits.device, + ) + target_classes[idx] = target_classes_o + + if self.loss_class_type == "focal_loss": + loss_ce = focal_loss( + src_logits.transpose(1, 2), + target_classes, + alpha=self.focal_alpha, + gamma=self.focal_gamma, + ) + elif self.loss_class_type == "ce_loss": + loss_ce = F.cross_entropy(src_logits.transpose(1, 2), target_classes, self.empty_weight) + elif self.loss_class_type == "bce_loss": + target = F.one_hot(target_classes, num_classes=self.num_classes + 1)[..., :-1] + if self.cls_sigmoid and src_logits.shape[-1] > target.shape[-1]: + src_logits = src_logits[..., :-1] + loss = F.binary_cross_entropy_with_logits(src_logits, target * 1.0, reduction="none") + loss_ce = loss.mean(1).sum() * src_logits.shape[1] / num_masks + + elif self.loss_class_type == "bce_focal_loss": + # src_logits: (b, num_queries, num_classes) = (2, 300, 80) + # target_classes_one_hot = (2, 300, 80) + target = F.one_hot(target_classes, num_classes=self.num_classes + 1)[..., :-1] + loss = torchvision.ops.sigmoid_focal_loss( + src_logits, + target.float(), + self.focal_alpha, + self.focal_gamma, + reduction="none", + ) + loss_ce = loss.mean(1).sum() * src_logits.shape[1] / num_masks + else: + raise ValueError("loss_class_type must be in ['ce_loss', 'focal_loss', 'bce_loss', 'bce_focal_loss']") + + losses = {"loss_ce": loss_ce} + return losses + + def loss_masks(self, outputs, targets: list[BisenetFormerTargets], indices, num_masks): + """Compute the losses related to the masks: the focal loss and the dice loss. + targets dicts must contain the key "masks" containing a tensor of dim [nb_target_boxes, h, w] + """ + assert "pred_masks" in outputs + + src_idx = self._get_src_permutation_idx(indices) + tgt_idx = self._get_tgt_permutation_idx(indices) + src_masks = outputs["pred_masks"] + src_masks = src_masks[src_idx] + masks = [t.masks for t in targets] + # TODO use valid to mask invalid areas due to padding in loss + target_masks, valid = nested_tensor_from_tensor_list(masks).decompose() + target_masks = target_masks.to(src_masks) + target_masks = target_masks[tgt_idx] + + # No need to upsample predictions as we are using normalized coordinates :) + # N x 1 x H x W + src_masks = src_masks[:, None] + target_masks = target_masks[:, None] + + if self.num_points != 0: + with torch.no_grad(): + # sample point_coords + point_coords = get_uncertain_point_coords_with_randomness( + src_masks, + lambda logits: calculate_uncertainty(logits), + self.num_points, + self.oversample_ratio, + self.importance_sample_ratio, + ) + # get gt labels + point_labels = point_sample( + target_masks, + point_coords, + align_corners=False, + ).squeeze(1) + + point_logits = point_sample( + src_masks, + point_coords, + align_corners=False, + ).squeeze(1) + else: + src_masks = F.interpolate( + src_masks[:, None], + size=target_masks.shape[-2:], + mode="bilinear", + align_corners=False, + ) + point_logits = src_masks[:, 0].flatten(1) + + target_masks = target_masks.flatten(1) + point_labels = target_masks.view(src_masks.shape) + + losses = { + "loss_mask": sigmoid_ce_loss_jit(point_logits, point_labels, num_masks), + "loss_dice": dice_loss_jit(point_logits, point_labels, num_masks), + } + + del src_masks + del target_masks + return losses + + def _get_src_permutation_idx(self, indices): + # permute predictions following indices + batch_idx = torch.cat([torch.full_like(src, i) for i, (src, _) in enumerate(indices)]) + src_idx = torch.cat([src for (src, _) in indices]) + return batch_idx, src_idx + + def _get_tgt_permutation_idx(self, indices): + # permute targets following indices + batch_idx = torch.cat([torch.full_like(tgt, i) for i, (_, tgt) in enumerate(indices)]) + tgt_idx = torch.cat([tgt for (_, tgt) in indices]) + return batch_idx, tgt_idx + + def get_loss(self, loss, outputs, targets, indices, num_masks, **kwargs): + loss_map = { + "labels": self.loss_labels, + "masks": self.loss_masks, + } + assert loss in loss_map, f"do you really want to compute {loss} loss?" + return loss_map[loss](outputs, targets, indices, num_masks, **kwargs) + + def forward(self, outputs, targets: list[BisenetFormerTargets]): + """This performs the loss computation. + Parameters: + outputs: dict of tensors, see the output specification of the model for the format + targets: list of dicts, such that len(targets) == batch_size. + The expected keys in each dict depends on the losses applied, see each loss' doc + """ + outputs_without_aux = {k: v for k, v in outputs.items() if k != "aux_outputs"} + + # Retrieve the matching between the outputs of the last layer and the targets + indices = self.matcher(outputs_without_aux, targets) + + # Compute the average number of target boxes accross all nodes, for normalization purposes + num_masks = sum(len(t.labels) for t in targets) + num_masks = torch.as_tensor([num_masks], dtype=torch.float, device=next(iter(outputs.values())).device) + if is_dist_available_and_initialized(): + torch.distributed.all_reduce(num_masks) + num_masks = torch.clamp(num_masks / get_world_size(), min=1).item() + + # Compute all the requested losses + losses = {} + for loss in self.losses: + l_dict = self.get_loss(loss, outputs, targets, indices, num_masks) + for k in list(l_dict.keys()): + if k in self.weight_dict: + l_dict[k] *= self.weight_dict[k] + losses.update(l_dict) + + # In case of auxiliary losses, we repeat this process with the output of each intermediate layer. + if "aux_outputs" in outputs and self.deep_supervision: + for i, aux_outputs in enumerate(outputs["aux_outputs"]): + indices = self.matcher(aux_outputs, targets) + for loss in self.losses: + l_dict = self.get_loss(loss, aux_outputs, targets, indices, num_masks) + for k in list(l_dict.keys()): + if k in self.weight_dict: + l_dict[k] *= self.weight_dict[k] + + l_dict = {k + f"_{i}": v for k, v in l_dict.items()} + + losses.update(l_dict) + + # In case of cdn auxiliary losses. For rtdetr + if "dn_aux_outputs" in outputs: + assert "dn_meta" in outputs, "" + indices = self.get_cdn_matched_indices(outputs["dn_meta"], targets) + num_masks = num_masks * outputs["dn_meta"]["dn_num_group"] + + for i, aux_outputs in enumerate(outputs["dn_aux_outputs"]): + # indices = self.matcher(aux_outputs, targets) + for loss in self.losses: + if loss == "masks": + # Intermediate masks losses are too costly to compute, we ignore them. + continue + l_dict = self.get_loss(loss, aux_outputs, targets, indices, num_masks) + l_dict = {k: l_dict[k] * self.weight_dict[k] for k in l_dict if k in self.weight_dict} + l_dict = {k + f"_dn_{i}": v for k, v in l_dict.items()} + losses.update(l_dict) + + return losses + + def __repr__(self): + head = "Criterion " + self.__class__.__name__ + body = [ + "matcher: {}".format(self.matcher), + "losses: {}".format(self.losses), + "weight_dict: {}".format(self.weight_dict), + "num_classes: {}".format(self.num_classes), + "eos_coef: {}".format(self.eos_coef), + "num_points: {}".format(self.num_points), + "oversample_ratio: {}".format(self.oversample_ratio), + "importance_sample_ratio: {}".format(self.importance_sample_ratio), + ] + _repr_indent = 4 + lines = [head] + [" " * _repr_indent + line for line in body] + return "\n".join(lines) + + +class MaskHungarianMatcher(nn.Module): + """This class computes an assignment between the targets and the predictions of the network + + For efficiency reasons, the targets don't include the no_object. Because of this, in general, + there are more predictions than targets. In this case, we do a 1-to-1 matching of the best predictions, + while the others are un-matched (and thus treated as non-objects). + """ + + def __init__( + self, + cost_class: float = 1, + cost_mask: float = 1, + cost_dice: float = 1, + num_points: int = 0, + cls_sigmoid: bool = False, + ): + """Creates the matcher + + Params: + cost_class: This is the relative weight of the classification error in the matching cost + cost_mask: This is the relative weight of the focal loss of the binary mask in the matching cost + cost_dice: This is the relative weight of the dice loss of the binary mask in the matching cost + num_points: Number of points to sample from the mask for matching + cls_sigmoid: Whether to apply sigmoid to the classification logits + """ + super().__init__() + self.cost_class = cost_class + self.cost_mask = cost_mask + self.cost_dice = cost_dice + self.cls_sigmoid = cls_sigmoid + + assert cost_class != 0 or cost_mask != 0 or cost_dice != 0, "all costs cant be 0" + + self.num_points = num_points + + @torch.no_grad() + def memory_efficient_forward(self, outputs, targets: list[BisenetFormerTargets]): + """More memory-friendly matching""" + bs, num_queries = outputs["pred_logits"].shape[:2] + + indices = [] + + # Iterate through batch size + for b in range(bs): + if self.cls_sigmoid: + out_prob = outputs["pred_logits"][b].sigmoid() + else: + out_prob = outputs["pred_logits"][b].softmax(-1) # [num_queries, num_classes] + tgt_ids = targets[b].labels + + # Compute the classification cost. Contrary to the loss, we don't use the NLL, + # but approximate it in 1 - proba[target class]. + # The 1 is a constant that doesn't change the matching, it can be ommitted. + cost_class = -out_prob[:, tgt_ids] + + out_mask = outputs["pred_masks"][b] # [num_queries, H_pred, W_pred] + # gt masks are already padded when preparing target + tgt_mask = targets[b].masks.to(out_mask) + + out_mask = out_mask[:, None] + tgt_mask = tgt_mask[:, None] + # all masks share the same set of points for efficient matching! + point_coords = torch.rand(1, self.num_points, 2, device=out_mask.device) + # get gt labels + tgt_mask = point_sample( + tgt_mask, + point_coords.repeat(tgt_mask.shape[0], 1, 1), + align_corners=False, + ).squeeze(1) + + out_mask = point_sample( + out_mask, + point_coords.repeat(out_mask.shape[0], 1, 1), + align_corners=False, + ).squeeze(1) + + with autocast(device_type="cuda", enabled=False): + out_mask = out_mask.float() + tgt_mask = tgt_mask.float() + # Compute the focal loss between masks + cost_mask = batch_sigmoid_ce_loss_jit(out_mask, tgt_mask) + + # Compute the dice loss betwen masks + cost_dice = batch_dice_loss_jit(out_mask, tgt_mask) + + # Final cost matrix + C = self.cost_mask * cost_mask + self.cost_class * cost_class + self.cost_dice * cost_dice + C = C.reshape(num_queries, -1).cpu() + + indices.append(linear_sum_assignment(C)) + + return [ + ( + torch.as_tensor(i, dtype=torch.int64), + torch.as_tensor(j, dtype=torch.int64), + ) + for i, j in indices + ] + + @torch.no_grad() + def forward(self, outputs, targets): + """Performs the matching + + Params: + outputs: This is a dict that contains at least these entries: + "pred_logits": Tensor of dim [batch_size, num_queries, num_classes] with the classification logits + "pred_masks": Tensor of dim [batch_size, num_queries, H_pred, W_pred] with the predicted masks + + targets: This is a list of targets (len(targets) = batch_size), where each target is a dict containing: + "labels": Tensor of dim [num_target_boxes] (where num_target_boxes is the number of ground-truth + objects in the target) containing the class labels + "masks": Tensor of dim [num_target_boxes, H_gt, W_gt] containing the target masks + + Returns: + A list of size batch_size, containing tuples of (index_i, index_j) where: + - index_i is the indices of the selected predictions (in order) + - index_j is the indices of the corresponding selected targets (in order) + For each batch element, it holds: + len(index_i) = len(index_j) = min(num_queries, num_target_boxes) + """ + return self.memory_efficient_forward(outputs, targets) + + def __repr__(self, _repr_indent=4): + head = "Matcher " + self.__class__.__name__ + body = [ + "cost_class: {}".format(self.cost_class), + "cost_mask: {}".format(self.cost_mask), + "cost_dice: {}".format(self.cost_dice), + ] + lines = [head] + [" " * _repr_indent + line for line in body] + return "\n".join(lines) diff --git a/focoos/models/bisenetformer/modelling.py b/focoos/models/bisenetformer/modelling.py new file mode 100644 index 00000000..c100d1b3 --- /dev/null +++ b/focoos/models/bisenetformer/modelling.py @@ -0,0 +1,622 @@ +from typing import Dict, Optional + +import torch +import torch.nn as nn +import torch.nn.functional as F + +from focoos.models.bisenetformer.config import BisenetFormerConfig +from focoos.models.bisenetformer.loss import MaskHungarianMatcher, SetCriterion +from focoos.models.bisenetformer.ports import BisenetFormerOutput, BisenetFormerTargets +from focoos.models.focoos_model import BaseModelNN +from focoos.nn.backbone.base import BaseBackbone +from focoos.nn.backbone.build import load_backbone +from focoos.nn.layers.base import MLP +from focoos.nn.layers.conv import Conv2d +from focoos.nn.layers.position_encoding import PositionEmbeddingSine +from focoos.nn.layers.transformer import ( + CrossAttentionLayer, + FFNLayer, + SelfAttentionLayer, +) +from focoos.utils.logger import get_logger + +logger = get_logger(__name__) + + +class PredictionHeads(nn.Module): + """Prediction heads for mask classification and segmentation.""" + + def __init__( + self, + hidden_dim, + num_classes, + mask_dim, + num_heads, + mask_classification, + use_attn_masks, + ): + """Initialize prediction heads. + + Args: + hidden_dim: Dimension of hidden features + num_classes: Number of classes to predict + mask_dim: Dimension of mask features + num_heads: Number of attention heads + mask_classification: Whether to perform mask classification + use_attn_masks: Whether to use attention masks + """ + super().__init__() + self.decoder_norm = nn.LayerNorm(hidden_dim) + # output FFNs + self.classifier = nn.Linear(hidden_dim, num_classes + 1) + self.mask_classifier = MLP(hidden_dim, hidden_dim, mask_dim, 3) + + self.num_heads = num_heads + self.use_attn_masks = use_attn_masks + self.num_classes = num_classes + + def reset_classifier(self, num_classes: Optional[int] = None): + """Reset the classifier with a new number of classes. + + Args: + num_classes: New number of classes (optional) + """ + _num_classes = num_classes if num_classes else self.num_classes + self.classifier = nn.Linear(self.classifier.in_features, _num_classes + 1).to(self.classifier.weight.device) + + def forward(self, x, mask_features, sizes=None, process=True): + """Forward pass for prediction heads. + + Args: + x: Input features + mask_features: Mask features + sizes: Target sizes for attention masks + process: Whether to process attention masks + + Returns: + Class logits, mask predictions, and optionally attention masks + """ + decoder_output = self.decoder_norm(x) + decoder_output = decoder_output.transpose(0, 1) + # just a linear layer [hidden, n_class + 1] + outputs_class = self.classifier(decoder_output) + mask_embed = self.mask_classifier(decoder_output) # MLP with 3 linear layer + outputs_mask = torch.einsum("bqc,bchw->bqhw", mask_embed, mask_features) + + if sizes is not None: + if self.use_attn_masks: + attn_masks = [] + if not isinstance(sizes, list): + sizes = [sizes] + for attn_mask_target_size in sizes: + # NOTE: prediction is of higher-resolution + # [B, Q, H, W] -> [B, Q, H*W] -> [B, h, Q, H*W] -> [B*h, Q, HW] + attn_mask = F.interpolate( + outputs_mask, + size=attn_mask_target_size, + mode="bilinear", + align_corners=False, + ) + if process: + # must use bool type + # If a BoolTensor is provided, positions with ``True`` are not allowed to attend while ``False`` values will be unchanged. + attn_mask = attn_mask.flatten(2) < 0 + attn_mask = attn_mask.detach() if self.training else attn_mask + attn_masks.append(attn_mask) + else: + attn_masks = None + return outputs_class, outputs_mask, attn_masks + else: + return outputs_class, outputs_mask + + def forward_class_only(self, x): + """Forward pass for class prediction only. + + Args: + x: Input features + + Returns: + Class logits + """ + decoder_output = self.decoder_norm(x) + decoder_output = decoder_output.transpose(0, 1) + # just a linear layer [hidden, n_class + 1] + outputs_class = self.classifier(decoder_output) + return outputs_class + + +class ConvBNReLU(nn.Module): + def __init__(self, in_chan, out_chan, ks=3, stride=1, padding=1): + super().__init__() + self.conv = nn.Conv2d( + in_chan, + out_chan, + kernel_size=ks, + stride=stride, + padding=padding, + bias=False, + ) + self.bn = nn.BatchNorm2d(out_chan) + self.relu = nn.ReLU() + + def forward(self, x): + x = self.conv(x) + x = self.bn(x) + x = self.relu(x) + return x + + +class AttentionRefinementModule(nn.Module): + def __init__(self, in_chan, out_chan, *args, **kwargs): + super().__init__() + self.proj = nn.Conv2d(in_chan, out_chan, kernel_size=1, bias=False) + self.conv = ConvBNReLU(out_chan, out_chan, ks=3, stride=1, padding=1) + self.conv_atten = nn.Conv2d(out_chan, out_chan, kernel_size=1, bias=False) + self.bn_atten = nn.BatchNorm2d(out_chan) + + self.sigmoid_atten = nn.Sigmoid() + + def forward(self, x): + feat = self.conv(self.proj(x)) + # feat = self.conv(x) + atten = feat.mean(dim=(2, 3), keepdim=True) + atten = self.conv_atten(atten) + atten = self.bn_atten(atten) + atten = self.sigmoid_atten(atten) + out = torch.mul(feat, atten) + return out + + +class ContextPath(nn.Module): + def __init__(self, inplanes, hidden_dim=128, out4=False): + super().__init__() + # inplanes -> 0 res2 1/4, 1 res3 1/8, 2 res4 1/16, 3 res5 1/32 + self.arm32 = AttentionRefinementModule(inplanes[3], hidden_dim) + self.conv_avg = ConvBNReLU(inplanes[3], hidden_dim, ks=1, stride=1, padding=0) + self.conv_head32 = ConvBNReLU(hidden_dim, hidden_dim, ks=3, stride=1, padding=1) + + self.arm16 = AttentionRefinementModule(inplanes[2], hidden_dim) + self.conv_head16 = ConvBNReLU(hidden_dim, hidden_dim, ks=3, stride=1, padding=1) + + self.out4 = out4 + if self.out4: + self.arm8 = AttentionRefinementModule(inplanes[1], hidden_dim) + self.conv_head8 = ConvBNReLU(hidden_dim, hidden_dim, ks=3, stride=1, padding=1) + + def forward(self, feat4, feat8, feat16, feat32): + avg = feat32.mean(dim=(2, 3), keepdim=True) + + avg = self.conv_avg(avg) + + feat32_arm = self.arm32(feat32) + feat32_sum = feat32_arm + avg + feat32_up = F.interpolate(feat32_sum, size=feat16.shape[-2:], mode="bilinear") + feat32_up = self.conv_head32(feat32_up) + + feat16_arm = self.arm16(feat16) + feat16_sum = feat16_arm + feat32_up + feat16_up = F.interpolate(feat16_sum, size=feat8.shape[-2:], mode="bilinear") + feat16_up = self.conv_head16(feat16_up) + + if self.out4: + feat8_arm = self.arm8(feat8) + feat8_sum = feat8_arm + feat16_up + feat8_up = F.interpolate(feat8_sum, size=feat4.shape[-2:], mode="bilinear") + feat8_up = self.conv_head8(feat8_up) + else: + feat8_sum = feat16_up + feat8_up = None + + return feat8_up, feat8_sum, feat16_sum, feat32_sum # x4, x8, x16, x32 + + +class FeatureFusionModule(nn.Module): + def __init__(self, in_chan1, in_chan2, out_chan, *args, **kwargs): + super().__init__() + self.proj1 = nn.Conv2d(in_chan1, out_chan, kernel_size=1) + self.proj2 = nn.Conv2d(in_chan2, out_chan, kernel_size=1) + self.convblk = ConvBNReLU(out_chan, out_chan, ks=1, stride=1, padding=0) + self.conv1 = nn.Conv2d(out_chan, out_chan // 4, kernel_size=1, stride=1, padding=0, bias=False) + self.conv2 = nn.Conv2d(out_chan // 4, out_chan, kernel_size=1, stride=1, padding=0, bias=False) + self.relu = nn.ReLU(inplace=True) + self.sigmoid = nn.Sigmoid() + + def forward(self, fsp, fcp): + # fcat = torch.cat([fsp, fcp], dim=1) + # feat = self.convblk(fcat) # self.proj1(fsp) + self.proj2(fcp)) + feat = self.convblk(self.proj1(fsp) + self.proj2(fcp)) + atten = F.adaptive_avg_pool2d(feat, 1) + atten = self.conv1(atten) + atten = self.relu(atten) + atten = self.conv2(atten) + atten = self.sigmoid(atten) + feat_atten = torch.mul(feat, atten) + feat_out = feat_atten + feat + return feat_out + + +class BiseNet(nn.Module): + def __init__( + self, + backbone: BaseBackbone, + feat_dim: int, + out_dim: int, + ): + """ + Args: + backbone: basic backbones to extract features from images + feat_dim: number of output channels for the intermediate conv layers. + out_dim: number of output channels for the final conv layer. + """ + super().__init__() + + self.backbone = backbone + self.input_shape = sorted(backbone.output_shape().items(), key=lambda x: x[1].stride) # type: ignore + # starting from "res2" to "res5" + self.in_features = [k for k, v in self.input_shape] + # starting from "res2" to "res5" + self.in_channels = [v.channels for k, v in self.input_shape] + self.in_strides = [v.stride for k, v in self.input_shape] + self.out_dim = out_dim + self.feat_dim = feat_dim + feature_channels = [v.channels for k, v in self.input_shape] + # from res2 to res5 + self.cp = ContextPath(feature_channels, self.feat_dim) + self.ffm = FeatureFusionModule(feature_channels[1], self.feat_dim, self.feat_dim) + self.conv_out = ConvBNReLU(feat_dim, out_dim, ks=3, stride=1, padding=1) + + @property + def padding_constraints(self) -> Dict[str, int]: + return self.backbone.padding_constraints + + def forward(self, images: torch.Tensor): + features = self.backbone(images) + return self.forward_features(features) + + def forward_features(self, features): + res2, res3, res4, res5 = (features[f] for f in self.in_features) + _, feat_cp8, feat_cp16, feat_cp32 = self.cp(res2, res3, res4, res5) + feat_fuse = self.ffm(res3, feat_cp8) + feat_out = self.conv_out(feat_fuse) + + return feat_out, (feat_cp32, feat_cp16, feat_cp8) + + +class TransformerDecoder(nn.Module): + def __init__( + self, + in_channels, + out_dim, + mask_classification=True, + *, + num_classes: int, + hidden_dim: int, + num_queries: int, + nheads: int, + dim_feedforward: int, + dec_layers: int, + pre_norm: bool, + enforce_input_project: bool, + use_attn_masks: bool = True, + ): + super().__init__() + + assert mask_classification, "Only support mask classification model" + self.mask_classification = mask_classification + self.use_attn_masks = use_attn_masks + + # positional encoding + N_steps = hidden_dim // 2 + self.pe_layer = PositionEmbeddingSine(N_steps, normalize=True) + + # define Transformer decoder here + self.num_heads = nheads + self.num_layers = dec_layers + self.transformer_self_attention_layers = nn.ModuleList() + self.transformer_cross_attention_layers = nn.ModuleList() + self.transformer_ffn_layers = nn.ModuleList() + + for _ in range(self.num_layers): + self.transformer_self_attention_layers.append( + SelfAttentionLayer( + d_model=hidden_dim, + nhead=nheads, + dropout=0.0, + normalize_before=pre_norm, + ) + ) + + self.transformer_cross_attention_layers.append( + CrossAttentionLayer( + d_model=hidden_dim, + nhead=nheads, + dropout=0.0, + normalize_before=pre_norm, + ) + ) + + self.transformer_ffn_layers.append( + FFNLayer( + d_model=hidden_dim, + dim_feedforward=dim_feedforward, + dropout=0.0, + normalize_before=pre_norm, + ) + ) + + self.num_queries = num_queries + + # learnable query features + self.query_feat = nn.Embedding(num_queries, hidden_dim) + # learnable query p.e. + self.query_embed = nn.Embedding(num_queries, hidden_dim) + + # level embedding (we use 2 scale) + self.num_feature_levels = min(2, dec_layers) + # self.level_embed = nn.Embedding(self.num_feature_levels, hidden_dim) + self.input_proj = nn.ModuleList() + for _ in range(min(self.num_feature_levels, dec_layers)): + if in_channels != hidden_dim or enforce_input_project: + self.input_proj.append(Conv2d(in_channels, hidden_dim, kernel_size=1)) + nn.init.kaiming_uniform_(self.input_proj[-1].weight, a=1) # type: ignore + if self.input_proj[-1].bias is not None: + nn.init.constant_(self.input_proj[-1].bias, 0) # type: ignore + else: + self.input_proj.append(nn.Sequential()) + + self.forward_prediction_heads = PredictionHeads( + hidden_dim, num_classes, out_dim, nheads, mask_classification, use_attn_masks + ) + + def forward(self, x, mask_features, targets=None, mask=None): + # x is a list of multi-scale feature + # assert len(x) == self.num_feature_levels + src = [] + pos = [] + size_list = [] + + x = x[:-1] # F1 and F2 only (not F3) + + # disable mask, it does not affect performance + del mask + + for i in range(self.num_feature_levels): + size_list.append(x[i].shape[-2:]) + pos.append(self.pe_layer(x[i], None).flatten(2)) + # + self.level_embed(i)[None, :, None]) + src.append(self.input_proj[i](x[i]).flatten(2)) + + # flatten NxCxHxW to HWxNxC + pos[-1] = pos[-1].permute(2, 0, 1) + src[-1] = src[-1].permute(2, 0, 1) + + _, bs, _ = src[0].shape + + # QxNxC + query_embed = self.query_embed.weight.unsqueeze(1).repeat(1, bs, 1) + output = self.query_feat.weight.unsqueeze(1).repeat(1, bs, 1) + + predictions_class = [] + predictions_mask = [] + + # prediction heads on learnable query features + outputs_class, outputs_mask, attn_mask = self.forward_prediction_heads( + output, mask_features, sizes=size_list[0] + ) + attn_mask = attn_mask[0] if self.use_attn_masks else None + predictions_class.append(outputs_class) + predictions_mask.append(outputs_mask) + + for i in range(self.num_layers): + level_index = i % self.num_feature_levels + # if on a mask is all True (no pixel active), use cross attention (put every pixel at False) + # B N # 1 if any False, 0 if all True + if attn_mask is not None: + m_mask = (attn_mask.sum(-1) != attn_mask.shape[-1]).unsqueeze(-1) + attn_mask = attn_mask.type_as(output) * m_mask.type_as(output) + attn_mask = attn_mask.bool().unsqueeze(1).repeat(1, self.num_heads, 1, 1).flatten(0, 1) + # attention: cross-attention first + output = self.transformer_cross_attention_layers[i]( + output, # query + src[level_index], # key and value + memory_mask=attn_mask if self.use_attn_masks else None, + memory_key_padding_mask=None, # here we do not apply masking on padded region + pos=pos[level_index], + query_pos=query_embed, + ) + + output = self.transformer_self_attention_layers[i]( + output, tgt_mask=None, tgt_key_padding_mask=None, query_pos=query_embed + ) + + # FFN + output = self.transformer_ffn_layers[i](output) + + outputs_class, outputs_mask, attn_mask = self.forward_prediction_heads( + output, mask_features, sizes=size_list[(i + 1) % self.num_feature_levels] + ) + attn_mask = attn_mask[0] if self.use_attn_masks else None + predictions_class.append(outputs_class) + predictions_mask.append(outputs_mask) + + assert len(predictions_class) == self.num_layers + 1 + + out = { + "pred_logits": predictions_class[-1], + "pred_masks": predictions_mask[-1], + "aux_outputs": self._set_aux_loss( + predictions_class if self.mask_classification else None, predictions_mask + ), + } + return out + + @torch.jit.unused + def _set_aux_loss(self, outputs_class, outputs_seg_masks): + # this is a workaround to make torchscript happy, as torchscript + # doesn't support dictionary with non-homogeneous values, such + # as a dict having both a Tensor and a list. + if self.mask_classification: + return [{"pred_logits": a, "pred_masks": b} for a, b in zip(outputs_class[:-1], outputs_seg_masks[:-1])] + else: + return [{"pred_masks": b} for b in outputs_seg_masks[:-1]] + + +class MaskFormerHead(nn.Module): + def __init__( + self, + *, + in_channels: int, + out_dim: int, + num_classes: int, + criterion: nn.Module, + ignore_value: int = -1, + # extra parameters + transformer_predictor: nn.Module, + cls_sigmoid=False, + ): + """ + Args: + num_classes: number of classes to predict + loss_weight: loss weight + ignore_value: category id to be ignored during training. + transformer_predictor: the transformer decoder that makes prediction + """ + super().__init__() + + self.in_channels = in_channels + self.out_dim = out_dim + self.ignore_value = ignore_value + self.criterion = criterion + + self.predictor = transformer_predictor + + self.num_classes = num_classes + self.metadata = None + self.cls_sigmoid = cls_sigmoid + self.mask_threshold = 0 + + def layers(self, features, targets=None, mask=None): + mask_features, multi_scale_features = features + predictions = self.predictor(multi_scale_features, mask_features, targets=targets, mask=mask) + + return predictions + + def forward(self, features, targets: list[BisenetFormerTargets] = []): + outputs = self.layers(features, targets=targets) + + loss = None + if targets is not None and len(targets) > 0: + loss = self.losses(outputs, targets) + + if isinstance(outputs, tuple): + outputs = outputs[0] + mask_cls = outputs["pred_logits"] + mask_pred = outputs["pred_masks"] + + if self.cls_sigmoid: + mask_cls = mask_cls.sigmoid()[..., :-1] + else: + mask_cls = F.softmax(mask_cls, dim=-1)[..., :-1] + mask_pred = mask_pred.sigmoid() + + return (mask_cls, mask_pred), loss + + def losses(self, predictions, targets): + if isinstance(predictions, tuple): + predictions, mask_dict = predictions + losses = self.criterion(predictions, targets, mask_dict) + else: + losses = self.criterion(predictions, targets) + + return losses + + +class BisenetFormer(BaseModelNN): + def __init__(self, config: BisenetFormerConfig): + super().__init__(config) + self._export = False + self.config = config + accepted_postprocessing_types = ["semantic", "instance"] + if self.config.postprocessing_type not in accepted_postprocessing_types: + raise ValueError( + f"Invalid postprocessing type: {self.config.postprocessing_type}. Must be one of: {accepted_postprocessing_types}" + ) + + backbone = load_backbone(self.config.backbone_config) + + self.pixel_decoder = BiseNet( + backbone=backbone, + feat_dim=self.config.pixel_decoder_feat_dim, + out_dim=self.config.pixel_decoder_out_dim, + ) + self.head = MaskFormerHead( + in_channels=self.config.transformer_predictor_out_dim, + out_dim=self.config.head_out_dim, + num_classes=self.config.num_classes, + ignore_value=255, + criterion=SetCriterion( + num_classes=self.config.num_classes, + matcher=MaskHungarianMatcher( + cost_class=self.config.matcher_cost_class, + cost_mask=self.config.matcher_cost_mask, + cost_dice=self.config.matcher_cost_dice, + num_points=self.config.criterion_num_points, + cls_sigmoid=self.config.cls_sigmoid, + ), + weight_dict={ + "loss_ce": self.config.weight_dict_loss_ce, + "loss_mask": self.config.weight_dict_loss_mask, + "loss_dice": self.config.weight_dict_loss_dice, + }, + deep_supervision=self.config.criterion_deep_supervision, + eos_coef=self.config.criterion_eos_coef, + losses=["labels", "masks"], + num_points=self.config.criterion_num_points, + oversample_ratio=3.0, + importance_sample_ratio=0.75, + loss_class_type="ce_loss" if not self.config.cls_sigmoid else "bce_loss", + cls_sigmoid=self.config.cls_sigmoid, + ), + transformer_predictor=TransformerDecoder( + in_channels=self.config.pixel_decoder_out_dim, + out_dim=self.config.transformer_predictor_out_dim, + num_classes=self.config.num_classes, + hidden_dim=self.config.transformer_predictor_hidden_dim, # this is query dim + num_queries=self.config.num_queries, + nheads=8, + dim_feedforward=self.config.transformer_predictor_dim_feedforward, + dec_layers=self.config.transformer_predictor_dec_layers, + pre_norm=True, + enforce_input_project=True, + use_attn_masks=True, + ), + cls_sigmoid=self.config.cls_sigmoid, + ) + self.top_k = self.config.num_queries + self.register_buffer("pixel_mean", torch.Tensor(self.config.pixel_mean).view(-1, 1, 1), False) + self.register_buffer("pixel_std", torch.Tensor(self.config.pixel_std).view(-1, 1, 1), False) + self.size_divisibility = self.config.size_divisibility + self.num_classes = self.config.num_classes + + @property + def device(self): + return self.pixel_mean.device + + @property + def dtype(self): + return self.pixel_mean.dtype + + def forward( + self, + images: torch.Tensor, + targets: list[BisenetFormerTargets] = [], + ) -> BisenetFormerOutput: + images = (images - self.pixel_mean) / self.pixel_std # type: ignore + + features = self.pixel_decoder(images) + (logits, masks), losses = self.head(features, targets) + + if not self.training: + masks = F.interpolate(masks, size=images.shape[2:], mode="bilinear", align_corners=False) + + return BisenetFormerOutput(masks=masks, logits=logits, loss=losses) diff --git a/focoos/models/bisenetformer/ports.py b/focoos/models/bisenetformer/ports.py new file mode 100644 index 00000000..79f547cf --- /dev/null +++ b/focoos/models/bisenetformer/ports.py @@ -0,0 +1,19 @@ +from dataclasses import dataclass +from typing import Optional + +import torch + +from focoos.ports import ModelOutput + + +@dataclass +class BisenetFormerOutput(ModelOutput): + masks: torch.Tensor # [N, num_queries, H, W] + logits: torch.Tensor # [N, num_queries, num_classes] + loss: Optional[dict] + + +@dataclass +class BisenetFormerTargets: + labels: torch.Tensor + masks: torch.Tensor diff --git a/focoos/models/bisenetformer/processor.py b/focoos/models/bisenetformer/processor.py new file mode 100644 index 00000000..842ed4ca --- /dev/null +++ b/focoos/models/bisenetformer/processor.py @@ -0,0 +1,341 @@ +from typing import Optional, Union + +import numpy as np +import torch +from PIL import Image + +from focoos.models.bisenetformer.config import BisenetFormerConfig +from focoos.models.bisenetformer.ports import BisenetFormerOutput, BisenetFormerTargets +from focoos.ports import DatasetEntry, DynamicAxes, FocoosDet, FocoosDetections +from focoos.processor.base_processor import Processor +from focoos.structures import BitMasks, ImageList, Instances +from focoos.utils.memory import retry_if_cuda_oom +from focoos.utils.vision import binary_mask_to_base64, masks_to_xyxy, trim_mask + + +def interpolate_image(image, size): + return torch.nn.functional.interpolate( + image.unsqueeze(0), + size=size, + mode="bilinear", + align_corners=False, + )[0] + + +class BisenetFormerProcessor(Processor): + def __init__(self, config: BisenetFormerConfig): + super().__init__(config) + self.config = config + processing_functions = { + "semantic": self.semantic_inference, + "instance": self.instance_inference, + } + self.eval_output_name = "sem_seg" if config.postprocessing_type == "semantic" else "instances" + assert config.postprocessing_type in processing_functions, ( + f"Invalid postprocessing type: {config.postprocessing_type}. Must be one of: {processing_functions.keys()}" + ) + self.processing_fn = processing_functions[config.postprocessing_type] + + self.num_classes = config.num_classes + self.top_k = config.top_k + self.mask_threshold = config.mask_threshold + self.use_mask_score = config.use_mask_score + self.predict_all_pixels = config.predict_all_pixels + self.threshold = config.threshold + + def preprocess( + self, + inputs: Union[ + torch.Tensor, + np.ndarray, + Image.Image, + list[Image.Image], + list[np.ndarray], + list[torch.Tensor], + list[DatasetEntry], + ], + device: torch.device, + dtype: torch.dtype = torch.float32, + image_size: Optional[int] = None, + ) -> tuple[torch.Tensor, list[BisenetFormerTargets]]: + targets = [] + if isinstance(inputs, list) and len(inputs) > 0 and isinstance(inputs[0], DatasetEntry): + images = [x.image.to(device) for x in inputs] # type: ignore + images = ImageList.from_tensors( + tensors=images, + ) + images_torch = images.tensor + if self.training: + # mask classification target + gt_instances = [x.instances.to(device) for x in inputs] # type: ignore + h, w = images.tensor.shape[-2:] + targets = [] + for targets_per_image in gt_instances: + assert targets_per_image.masks is not None, "masks are required for training" + gt_masks = targets_per_image.masks.tensor + if len(gt_masks) > 0: + padded_masks = torch.zeros( + (gt_masks.shape[0], h, w), + dtype=gt_masks.dtype, + device=gt_masks.device, + ) + padded_masks[:, : gt_masks.shape[1], : gt_masks.shape[2]] = gt_masks + else: + padded_masks = gt_masks + assert targets_per_image.classes is not None, "classes are required for training" + cls_labels = targets_per_image.classes + targets.append(BisenetFormerTargets(labels=cls_labels, masks=padded_masks)) + else: + if self.training: + raise ValueError("During training, inputs should be a list of DetectionDatasetDict") + images_torch = self.get_tensors(inputs).to(device, dtype=dtype) # type: ignore + # since we can process input of different sizes, we are not using image_size input + return images_torch, targets + + def semantic_inference( + self, + mask_cls, + mask_pred, + ) -> torch.Tensor: + semseg = torch.einsum("qc,qhw->chw", mask_cls, mask_pred) + return semseg + + def instance_inference( + self, + mask_cls, + mask_pred, + ) -> Instances: + # mask_pred is already processed to have the same shape as original input + image_size = mask_pred.shape[-2:] + num_queries = mask_pred.shape[0] + + # [Q, K] + # todo: merge this with the modeling top_k in the forward pass + scores = mask_cls + labels = ( + torch.arange(self.num_classes, device=mask_cls.device).unsqueeze(0).repeat(num_queries, 1).flatten(0, 1) + ) + # scores_per_image, topk_indices = scores.flatten(0, 1).topk(self.num_queries, sorted=False) + scores_per_image, topk_indices = scores.flatten(0, 1).topk(self.top_k, sorted=False) + labels_per_image = labels[topk_indices] + + topk_indices = topk_indices // self.num_classes + + mask_pred = mask_pred[topk_indices] + + bin_masks = mask_pred > self.mask_threshold + bin_masks = bin_masks * 1e-3 + mask_scores_per_image = (bin_masks.flatten(1) * mask_pred.flatten(1)).sum(1) / ( + bin_masks.flatten(1).sum(1) + 1e-6 + ) + + masks = BitMasks(bin_masks.float()) + boxes = masks.get_bounding_boxes() + scores = scores_per_image * mask_scores_per_image + classes = labels_per_image + return Instances(image_size, boxes=boxes, masks=masks, scores=scores, classes=classes) + + def eval_postprocess( + self, + output: BisenetFormerOutput, + batched_inputs: list[DatasetEntry], + ) -> list[dict[str, Union[Instances, torch.Tensor]]]: + results = [] + cls_pred = output.logits + mask_pred = output.masks + + for i in range(len(batched_inputs)): + # get "augmented" images size and next original size + size = batched_inputs[i].image.shape[-2:] # type: ignore + height = batched_inputs[i].height + width = batched_inputs[i].width + mask_pred_result = mask_pred[i] + mask_cls_result = cls_pred[i] + + out_stride = size[1] // mask_pred_result.shape[2] + mask_pred_result = mask_pred_result[:, : 1 + size[0] // out_stride, : 1 + size[1] // out_stride] + + mask_pred_result = retry_if_cuda_oom(interpolate_image)(mask_pred_result, (height, width)) + result = self.processing_fn(mask_cls_result, mask_pred_result) + results.append({self.eval_output_name: result}) + + return results + + def postprocess( + self, + output: BisenetFormerOutput, + inputs: Union[ + torch.Tensor, + np.ndarray, + Image.Image, + list[Image.Image], + list[np.ndarray], + list[torch.Tensor], + ], + class_names: list[str] = [], + top_k: Optional[int] = None, + threshold: Optional[float] = None, + use_mask_score: Optional[bool] = None, + predict_all_pixels: Optional[bool] = None, + ) -> list[FocoosDetections]: + top_k = top_k or self.top_k + threshold = threshold or self.threshold + use_mask_score = use_mask_score or self.use_mask_score + predict_all_pixels = predict_all_pixels or self.predict_all_pixels + + # Extract image sizes from inputs + image_sizes = self.get_image_sizes(inputs) + + batch_size = output.logits.shape[0] + results = [] + assert len(image_sizes) == batch_size, ( + f"Expected image sizes {len(image_sizes)} to match batch size {batch_size}" + ) + + cls_pred, mask_pred = ( + output.logits, + output.masks, + ) # B x Q; B x Q x H/out_stride x W/out_stride + # softmax done before. # B x Q; B x Q + scores, labels = cls_pred.max(-1) + + # # let's binarize the mask + if predict_all_pixels: + b, q, h, w = mask_pred.shape + p = scores.view(b, q, 1, 1) * mask_pred + out = p.argmax(dim=1) # Shape: [b, h, w] + + # Initialize an empty tensor for bin_mask_pred + bin_mask_pred = torch.zeros((b, q, h, w), dtype=torch.bool, device=mask_pred.device) + + # Process each batch instance separately + for batch_idx in range(b): + # Create a mask for each class in this batch + for class_idx in range(q): + # Set True where the argmax equals this class index + bin_mask_pred[batch_idx, class_idx] = out[batch_idx] == class_idx + else: + bin_mask_pred = mask_pred >= self.mask_threshold # B x Q x H x W + + # Find masks with zero sum + non_zero_masks = bin_mask_pred.sum(dim=(-2, -1)) > 1 # B x top_k_masks + # Set scores and labels to 0 for empty masks + # Get indices of non-zero masks + non_zero_indices = (non_zero_masks).nonzero(as_tuple=True) + # Filter scores, labels and bin_mask_pred to only keep non-zero masks + scores = torch.gather(scores, dim=1, index=non_zero_indices[1].unsqueeze(0)) + labels = torch.gather(labels, dim=1, index=non_zero_indices[1].unsqueeze(0)) + + bin_mask_pred = torch.gather( + bin_mask_pred, + dim=1, + index=non_zero_indices[1] + .unsqueeze(0) + .unsqueeze(-1) + .unsqueeze(-1) + .expand(-1, -1, *bin_mask_pred.shape[-2:]), + ) + + mask_pred = torch.gather( + mask_pred, + dim=1, + index=non_zero_indices[1].unsqueeze(0).unsqueeze(-1).unsqueeze(-1).expand(-1, -1, *mask_pred.shape[-2:]), + ) + + if use_mask_score: + bin_mask_pred = bin_mask_pred.int() + # Quickfix to avoid num. instability. + bin_mask_pred = bin_mask_pred * 1e-3 + mask_score = (bin_mask_pred * mask_pred).sum(-1).sum(-1) / ( + (bin_mask_pred).sum(-1).sum(-1) + 1e-5 + ) # add EPS to avoid division by 0 + # Multiply mask scores to class scores for final score + scores = scores * mask_score # B x Q + + # Filter based on the scores greather than threshold + if threshold > 0: + filter_mask = scores > threshold + filter_mask = filter_mask.nonzero(as_tuple=True) + scores = torch.gather(scores, dim=1, index=filter_mask[1].unsqueeze(0)) + labels = torch.gather(labels, dim=1, index=filter_mask[1].unsqueeze(0)) + bin_mask_pred = torch.gather( + bin_mask_pred, + dim=1, + index=filter_mask[1].unsqueeze(0).unsqueeze(-1).unsqueeze(-1).expand(-1, -1, *bin_mask_pred.shape[-2:]), + ) # B x top_k_masks x H x W + + bin_mask_pred = bin_mask_pred.detach().cpu() + scores = scores.detach().cpu() + labels = labels.detach().cpu() + + for i in range(batch_size): + if len(bin_mask_pred[i]) == 0: + results.append(FocoosDetections(detections=[])) + continue + # interpolate mask pred to original size + bin_mask_pred_resized = retry_if_cuda_oom(interpolate_image)( + bin_mask_pred[i].float(), image_sizes[i] + ).bool() + + box_pred = masks_to_xyxy(bin_mask_pred_resized.numpy()) + py_box_pred = box_pred.tolist() + + py_scores = scores[i].tolist() + py_labels = labels[i].tolist() + py_mask_pred = bin_mask_pred_resized.numpy() + + results.append( + FocoosDetections( + detections=[ + FocoosDet( + bbox=py_bp, + conf=py_s, + cls_id=py_l, + mask=binary_mask_to_base64(trim_mask(py_mp, py_bp)), + label=class_names[py_l] if class_names else None, + ) + for py_bp, py_s, py_l, py_mp in zip(py_box_pred, py_scores, py_labels, py_mask_pred) + ] + ) + ) + + return results + + def get_dynamic_axes(self) -> DynamicAxes: + return DynamicAxes( + input_names=["images"], + output_names=["logits", "masks"], + dynamic_axes={ + "images": {0: "batch", 2: "height", 3: "width"}, + }, + ) + + def export_postprocess( + self, + output: Union[list[torch.Tensor], list[np.ndarray]], + inputs: Union[ + torch.Tensor, + np.ndarray, + list[np.ndarray], + list[torch.Tensor], + ], + class_names: list[str] = [], + threshold: Optional[float] = None, + **kwargs, + ) -> list[FocoosDetections]: + masks = output[0] + logits = output[1] + device = torch.device("cuda" if torch.cuda.is_available() else "cpu") + if isinstance(logits, np.ndarray): + logits = torch.from_numpy(logits) + if isinstance(masks, np.ndarray): + masks = torch.from_numpy(masks) + + model_output = BisenetFormerOutput(logits=logits.to(device), masks=masks.to(device), loss=None) + return self.postprocess( + model_output, + inputs, + class_names, + threshold=threshold, + **kwargs, + ) diff --git a/focoos/models/fai_cls/__init__.py b/focoos/models/fai_cls/__init__.py new file mode 100644 index 00000000..a1acf0ec --- /dev/null +++ b/focoos/models/fai_cls/__init__.py @@ -0,0 +1,25 @@ +def _register_cls(): + from focoos.model_manager import ConfigManager, ModelManager + from focoos.ports import ModelFamily + from focoos.processor import ProcessorManager + + def load_classifier_model(): + # This import happens ONLY when load_cls_model is called + from focoos.models.fai_cls.modelling import FAIClassification + + return FAIClassification + + def load_classifier_config(): + from focoos.models.fai_cls.config import ClassificationConfig + + return ClassificationConfig + + def load_classifier_processor(): + from focoos.models.fai_cls.processor import ClassificationProcessor + + return ClassificationProcessor + + # Register the model and config loaders + ModelManager.register_model(ModelFamily.IMAGE_CLASSIFIER, load_classifier_model) + ConfigManager.register_config(ModelFamily.IMAGE_CLASSIFIER, load_classifier_config) + ProcessorManager.register_processor(ModelFamily.IMAGE_CLASSIFIER, load_classifier_processor) diff --git a/focoos/models/fai_cls/config.py b/focoos/models/fai_cls/config.py new file mode 100644 index 00000000..1c1a1956 --- /dev/null +++ b/focoos/models/fai_cls/config.py @@ -0,0 +1,29 @@ +from dataclasses import dataclass, field +from typing import List + +from focoos.nn.backbone.base import BackboneConfig +from focoos.ports import ModelConfig + + +@dataclass +class ClassificationConfig(ModelConfig): + backbone_config: BackboneConfig + num_classes: int + + # Image classification configuration + resolution: int = 224 + pixel_mean: List[float] = field(default_factory=lambda: [123.675, 116.28, 103.53]) + pixel_std: List[float] = field(default_factory=lambda: [58.395, 57.12, 57.375]) + + # Head configuration + hidden_dim: int = 512 + dropout_rate: float = 0.2 + features: str = "res5" + num_layers: int = 2 + + # Loss configuration + use_focal_loss: bool = False + focal_alpha: float = 0.75 + focal_gamma: float = 2.0 + label_smoothing: float = 0.0 + multi_label: bool = False diff --git a/focoos/models/fai_cls/modelling.py b/focoos/models/fai_cls/modelling.py new file mode 100644 index 00000000..2d795fe2 --- /dev/null +++ b/focoos/models/fai_cls/modelling.py @@ -0,0 +1,248 @@ +from typing import Dict, List + +import torch +import torch.nn as nn +import torch.nn.functional as F + +from focoos.data.mappers.classification_dataset_mapper import ClassificationDatasetDict +from focoos.models.fai_cls.config import ClassificationConfig +from focoos.models.fai_cls.ports import ClassificationModelOutput, ClassificationTargets +from focoos.models.focoos_model import BaseModelNN +from focoos.nn.backbone.build import load_backbone +from focoos.utils.logger import get_logger + +logger = get_logger(__name__) + + +class ClassificationHead(nn.Module): + """Classification head for image classification models.""" + + def __init__(self, in_features: int, hidden_dim: int, num_classes: int, num_layers: int, dropout_rate: float = 0.0): + """Initialize the classification head. + + Args: + in_features: Number of input features from backbone + hidden_dim: Hidden dimension for the classifier + num_classes: Number of output classes + num_layers: Number of layers in the classifier + dropout_rate: Dropout rate for regularization + """ + super().__init__() + + if num_layers == 2: + self.classifier = nn.Sequential( + nn.AdaptiveAvgPool2d(1), + nn.Flatten(), + nn.Linear(in_features, hidden_dim), + nn.ReLU(inplace=True), + nn.Dropout(dropout_rate), + nn.Linear(hidden_dim, num_classes), + ) + elif num_layers == 1: + self.classifier = nn.Sequential( + nn.AdaptiveAvgPool2d(1), + nn.Flatten(), + nn.Dropout(dropout_rate), + nn.Linear(in_features, num_classes), + ) + else: + raise ValueError(f"Invalid number of layers: {num_layers}") + + # Initialize weights + for m in self.modules(): + if isinstance(m, nn.Linear): + nn.init.trunc_normal_(m.weight, std=0.02) + if m.bias is not None: + nn.init.constant_(m.bias, 0) + + def forward(self, features): + """Forward pass of the classification head. + + Args: + features: Features from the backbone [N, C, H, W] + + Returns: + Classification logits [N, num_classes] + """ + return self.classifier(features) + + +class ClassificationLoss(nn.Module): + """Loss module for image classification tasks.""" + + def __init__( + self, + num_classes: int, + use_focal_loss: bool = False, + focal_alpha: float = 0.75, + focal_gamma: float = 2.0, + label_smoothing: float = 0.0, + multi_label: bool = False, + ): + """Initialize the loss module. + + Args: + num_classes: Number of classes + use_focal_loss: Whether to use focal loss + focal_alpha: Alpha parameter for focal loss + focal_gamma: Gamma parameter for focal loss + label_smoothing: Label smoothing parameter + multi_label: Whether to use multi-label loss + """ + super().__init__() + self.num_classes = num_classes + self.use_focal_loss = use_focal_loss + self.focal_alpha = focal_alpha + self.focal_gamma = focal_gamma + self.label_smoothing = label_smoothing + self.multi_label = multi_label + + # Use CrossEntropyLoss if not using focal loss + if not use_focal_loss and not multi_label: + self.ce_loss = nn.CrossEntropyLoss(label_smoothing=label_smoothing) + elif not use_focal_loss and multi_label: + self.ce_loss = nn.BCEWithLogitsLoss() + + def forward(self, logits: torch.Tensor, targets: List[ClassificationTargets]) -> Dict[str, torch.Tensor]: + """Compute the classification loss. + + Args: + logits: Classification logits [N, num_classes] + targets: List of classification targets + + Returns: + Dictionary with loss values + """ + labels = torch.stack([target.labels for target in targets]).to(logits.device) + + if self.use_focal_loss: + # Compute focal loss manually + pred_softmax = F.softmax(logits, dim=1) if not self.multi_label else torch.sigmoid(logits) + target_one_hot = F.one_hot(labels, num_classes=self.num_classes).float() + + # Apply label smoothing if needed + if self.label_smoothing > 0: + target_one_hot = target_one_hot * (1 - self.label_smoothing) + self.label_smoothing / self.num_classes + + # Compute focal loss + pred_softmax = torch.clamp(pred_softmax, min=1e-6, max=1.0) + if self.multi_label: + loss = ( + -self.focal_alpha + * ((1 - pred_softmax) ** self.focal_gamma) + * (target_one_hot * torch.log(pred_softmax) + (1 - target_one_hot) * torch.log(1 - pred_softmax)) + ) + else: + loss = ( + -self.focal_alpha + * ((1 - pred_softmax) ** self.focal_gamma) + * target_one_hot + * torch.log(pred_softmax) + ) + loss = loss.sum(dim=1).mean() + else: + if self.multi_label: + target_one_hot = F.one_hot(labels, num_classes=self.num_classes).float() + # Use standard cross entropy loss + loss = self.ce_loss(logits, target_one_hot) + else: + # Use standard cross entropy loss + loss = self.ce_loss(logits, labels) + + return {"loss_cls": loss} + + +class FAIClassification(BaseModelNN): + """Image classification model that can use any backbone.""" + + def __init__(self, config: ClassificationConfig): + """Initialize the classification model. + + Args: + config: Model configuration + """ + super().__init__(config) + + self.config = config + + self.register_buffer("pixel_mean", torch.Tensor(self.config.pixel_mean).view(-1, 1, 1), False) + self.register_buffer("pixel_std", torch.Tensor(self.config.pixel_std).view(-1, 1, 1), False) + + # Load backbone + self.backbone = load_backbone(config.backbone_config) + + # Use the highest level feature by default (e.g., res5 for ResNet) + assert config.features in self.backbone.output_shape() + self.in_features = config.features + self.feature_channels = self.backbone.output_shape()[self.in_features].channels + assert self.feature_channels is not None + + # Create classification head + self.cls_head = ClassificationHead( + in_features=self.feature_channels, + num_layers=config.num_layers, + hidden_dim=config.hidden_dim, + num_classes=config.num_classes, + dropout_rate=config.dropout_rate, + ) + + # Create loss module + self.criterion = ClassificationLoss( + num_classes=config.num_classes, + use_focal_loss=config.use_focal_loss, + focal_alpha=config.focal_alpha, + focal_gamma=config.focal_gamma, + label_smoothing=config.label_smoothing, + multi_label=config.multi_label, + ) + + @property + def device(self): + return self.pixel_mean.device + + @property + def dtype(self): + return self.pixel_mean.dtype + + def forward( + self, + images: torch.Tensor, + targets: list[ClassificationTargets] = [], + ) -> ClassificationModelOutput: + """Forward pass of the classification model. + + Args: + inputs: Input images or dataset dictionaries + + Returns: + Classification model output with logits and optional loss + """ + + images = (images - self.pixel_mean) / self.pixel_std # type: ignore + # Extract features from backbone + features = self.backbone(images) + + # Extract the highest level feature + feature_map = features[self.in_features] + + # Apply classification head + logits = self.cls_head(feature_map) + + # Compute loss if targets are provided (training mode) + loss = self.criterion(logits, targets) if targets else None + + return ClassificationModelOutput(logits=logits, loss=loss) + + def eval_post_process( + self, outputs: ClassificationModelOutput, inputs: List[ClassificationDatasetDict] + ) -> List[Dict]: + """Post-process model outputs for inference. + + Args: + outputs: Model outputs + batched_inputs: Batch input metadata + + Returns: + Processed results with classification predictions + """ + return self.processor.eval_postprocess(outputs, inputs) # type: ignore diff --git a/focoos/models/fai_cls/ports.py b/focoos/models/fai_cls/ports.py new file mode 100644 index 00000000..abae3ac4 --- /dev/null +++ b/focoos/models/fai_cls/ports.py @@ -0,0 +1,17 @@ +from dataclasses import dataclass +from typing import Optional + +import torch + +from focoos.ports import ModelOutput + + +@dataclass +class ClassificationModelOutput(ModelOutput): + logits: torch.Tensor # [N, num_classes] + loss: Optional[dict] + + +@dataclass +class ClassificationTargets: + labels: torch.Tensor # [N], class indices diff --git a/focoos/models/fai_cls/processor.py b/focoos/models/fai_cls/processor.py new file mode 100644 index 00000000..08528cd8 --- /dev/null +++ b/focoos/models/fai_cls/processor.py @@ -0,0 +1,188 @@ +from typing import Dict, List, Optional, Tuple, Union + +import numpy as np +import torch +import torch.nn.functional as F +from PIL import Image + +from focoos.data.mappers.classification_dataset_mapper import ClassificationDatasetDict +from focoos.models.fai_cls.config import ClassificationConfig +from focoos.models.fai_cls.ports import ClassificationModelOutput, ClassificationTargets +from focoos.ports import DatasetEntry, DynamicAxes, FocoosDet, FocoosDetections +from focoos.processor.base_processor import Processor +from focoos.structures import ImageList + + +class ClassificationProcessor(Processor): + """Processor for image classification model inputs and outputs.""" + + def __init__(self, config: ClassificationConfig): + """Initialize the processor with model configuration. + + Args: + config: Model configuration + """ + super().__init__(config) + self.config = config + self.multi_label = config.multi_label + + def preprocess( + self, + inputs: Union[ + torch.Tensor, + np.ndarray, + Image.Image, + List[Image.Image], + List[np.ndarray], + List[torch.Tensor], + List[ClassificationDatasetDict], + ], + device: torch.device, + dtype: torch.dtype, + image_size: Optional[int] = None, + ) -> Tuple[torch.Tensor, List[ClassificationTargets]]: + """Process input images for model inference. + + Args: + inputs: Input images in various formats + training: Whether the model is in training mode + device: Device to run the model on + dtype: Data type to use for the model + resolution: Resolution of the model + + Returns: + Tuple of processed tensors and batch inputs metadata + """ + targets = [] + if isinstance(inputs, list) and len(inputs) > 0 and isinstance(inputs[0], ClassificationDatasetDict): + class_data_dict: List[ClassificationDatasetDict] = inputs # type: ignore + images = [x.image.to(device) for x in class_data_dict] # type: ignore + images = ImageList.from_tensors( + tensors=images, + ) + images_torch = images.tensor + targets = [ + ClassificationTargets(labels=torch.tensor(x.label, dtype=torch.int64, device=device)) + for x in class_data_dict # type: ignore + ] + return images_torch, targets + + if self.training: + raise ValueError("During training, inputs should be a list of DetectionDatasetDict") + images_torch = self.get_tensors(inputs).to(device, dtype=dtype) # type: ignore + if image_size is not None: + images_torch = torch.nn.functional.interpolate( + images_torch, size=(image_size, image_size), mode="bilinear", align_corners=False + ) + return images_torch, targets + + def eval_postprocess(self, outputs: ClassificationModelOutput, inputs: list[DatasetEntry]) -> List[Dict]: + """Post-process model outputs. + + Args: + outputs: Model output + inputs: Batch input metadata + """ + if self.multi_label: + probs = F.sigmoid(outputs.logits) + else: + probs = F.softmax(outputs.logits, dim=1) + results = [] + for probs_i in probs: + results.append({"logits": probs_i}) + return results + + def postprocess( + self, + outputs: ClassificationModelOutput, + inputs: Union[ + torch.Tensor, + np.ndarray, + Image.Image, + list[Image.Image], + list[np.ndarray], + list[torch.Tensor], + ], + class_names: list[str] = [], + threshold: Optional[float] = None, + ) -> List[FocoosDetections]: + """Post-process model outputs. + + Args: + outputs: Model output + inputs: Batch input metadata + + Returns: + List of processed results with class probabilities and predicted class + """ + logits = outputs.logits.detach().cpu() + probs = F.softmax(logits, dim=1) + + results = [] + for i, probs_i in enumerate(probs): + top_prob, top_class = torch.max(probs_i, dim=0) + + result = FocoosDetections( + detections=[ + FocoosDet( + conf=top_prob.item(), + cls_id=int(top_class.item()), + label=class_names[int(top_class.item())] if class_names else None, + ) + ] + ) + results.append(result) + + return results + + def tensors_to_model_output( + self, tensors: Union[list[np.ndarray], list[torch.Tensor]] + ) -> ClassificationModelOutput: + """ + Convert a list of tensors or numpy arrays to a ClassificationModelOutput. + + Args: + tensors: List of tensors or numpy arrays + + Returns: + ClassificationModelOutput + """ + if not (isinstance(tensors, (list, tuple)) and len(tensors) == 1): + raise ValueError( + f"Expected a list or tuple of 1 element, got {type(tensors)} with length {len(tensors) if hasattr(tensors, '__len__') else 'N/A'}" + ) + if isinstance(tensors[0], np.ndarray): + new_tensor = torch.from_numpy(tensors[0]) + else: + new_tensor = tensors[0] + return ClassificationModelOutput(logits=new_tensor, loss=None) + + def get_dynamic_axes(self) -> DynamicAxes: + return DynamicAxes( + input_names=["images"], + output_names=["logits"], + dynamic_axes={ + "images": {0: "batch", 2: "height", 3: "width"}, + "logits": {0: "batch"}, + }, + ) + + def export_postprocess( + self, + output: Union[list[torch.Tensor], list[np.ndarray]], + inputs: Union[ + torch.Tensor, + np.ndarray, + Image.Image, + list[Image.Image], + list[np.ndarray], + list[torch.Tensor], + ], + threshold: Optional[float] = None, + **kwargs, + ) -> list[FocoosDetections]: + logits = output[0] + if isinstance(logits, np.ndarray): + logits = torch.from_numpy(logits) + model_output = ClassificationModelOutput(logits=logits, loss=None) + return self.postprocess(model_output, inputs, threshold=threshold, **kwargs) diff --git a/focoos/models/fai_detr/__init__.py b/focoos/models/fai_detr/__init__.py new file mode 100644 index 00000000..bb828e9c --- /dev/null +++ b/focoos/models/fai_detr/__init__.py @@ -0,0 +1,23 @@ +def _register(): + from focoos.model_manager import ConfigManager, ModelManager + from focoos.ports import ModelFamily + from focoos.processor import ProcessorManager + + def load_model(): + from focoos.models.fai_detr.modelling import FAIDetr + + return FAIDetr + + def load_config(): + from focoos.models.fai_detr.config import DETRConfig + + return DETRConfig + + def load_processor(): + from focoos.models.fai_detr.processor import DETRProcessor + + return DETRProcessor + + ModelManager.register_model(ModelFamily.DETR, load_model) + ConfigManager.register_config(ModelFamily.DETR, load_config) + ProcessorManager.register_processor(ModelFamily.DETR, load_processor) diff --git a/focoos/models/fai_detr/config.py b/focoos/models/fai_detr/config.py new file mode 100644 index 00000000..d0cb9d9c --- /dev/null +++ b/focoos/models/fai_detr/config.py @@ -0,0 +1,61 @@ +from dataclasses import dataclass, field +from typing import List + +from focoos.nn.backbone.base import BackboneConfig +from focoos.ports import ModelConfig + + +@dataclass +class DETRConfig(ModelConfig): + backbone_config: BackboneConfig + num_classes: int + + num_queries: int = 300 + resolution: int = 640 + + # Image detector configuration + pixel_mean: List[float] = field(default_factory=lambda: [123.675, 116.28, 103.53]) + pixel_std: List[float] = field(default_factory=lambda: [58.395, 57.12, 57.375]) + size_divisibility: int = 0 + + # Sizing configuration + pixel_decoder_out_dim: int = 256 + pixel_decoder_feat_dim: int = 256 + pixel_decoder_num_encoder_layers: int = 1 + pixel_decoder_expansion: float = 1.0 + pixel_decoder_dim_feedforward: int = 1024 + # Transformer decoder + transformer_predictor_out_dim: int = 256 + transformer_predictor_hidden_dim: int = 256 + transformer_predictor_dec_layers: int = 6 + transformer_predictor_dim_feedforward: int = 1024 + # Head configuration + head_out_dim: int = 256 + + # Transformer configurations + pixel_decoder_dropout: float = 0.0 + pixel_decoder_nhead: int = 8 + transformer_predictor_nhead: int = 8 + + # Post-processing configuration + threshold: float = 0.5 + top_k: int = 300 + + # Loss configuration + criterion_deep_supervision: bool = True + criterion_eos_coef: float = 0.1 + criterion_losses: List[str] = field(default_factory=lambda: ["vfl", "boxes"]) + criterion_num_points: int = 0 + criterion_focal_alpha: float = 0.75 + criterion_focal_gamma: float = 2.0 + + weight_dict_loss_vfl: int = 1 + weight_dict_loss_bbox: int = 5 + weight_dict_loss_giou: int = 2 + + matcher_cost_class: int = 2 + matcher_cost_bbox: int = 5 + matcher_cost_giou: int = 2 + matcher_use_focal_loss: bool = True + matcher_alpha: float = 0.25 + matcher_gamma: float = 2.0 diff --git a/focoos/models/fai_detr/modelling.py b/focoos/models/fai_detr/modelling.py new file mode 100644 index 00000000..077f6da8 --- /dev/null +++ b/focoos/models/fai_detr/modelling.py @@ -0,0 +1,1348 @@ +import copy +import math +from collections import OrderedDict +from typing import Dict + +import torch +import torch.nn as nn +import torch.nn.functional as F +import torch.nn.init as init +from scipy.optimize import linear_sum_assignment + +from focoos.models.fai_detr.config import DETRConfig +from focoos.models.fai_detr.ports import DETRModelOutput, DETRTargets +from focoos.models.focoos_model import BaseModelNN +from focoos.nn.backbone.base import BaseBackbone +from focoos.nn.backbone.build import load_backbone +from focoos.nn.layers.base import MLP +from focoos.nn.layers.conv import Conv2d, ConvNormLayer +from focoos.nn.layers.deformable import ms_deform_attn_core_pytorch +from focoos.nn.layers.functional import inverse_sigmoid +from focoos.nn.layers.transformer import TransformerEncoder, TransformerEncoderLayer +from focoos.utils.box import box_cxcywh_to_xyxy, box_iou, generalized_box_iou +from focoos.utils.distributed.comm import get_world_size +from focoos.utils.distributed.dist import is_dist_available_and_initialized +from focoos.utils.logger import get_logger + +logger = get_logger(__name__) + + +class RepVggBlock(nn.Module): + def __init__(self, ch_in, ch_out, act="relu"): + super().__init__() + self.ch_in = ch_in + self.ch_out = ch_out + self.conv1 = ConvNormLayer(ch_in, ch_out, 3, 1, padding=1, act=None) + self.conv2 = ConvNormLayer(ch_in, ch_out, 1, 1, padding=0, act=None) + self.act = nn.SiLU(inplace=True) + + def forward(self, x): + if hasattr(self, "conv"): + y = self.conv(x) + else: + y = self.conv1(x) + self.conv2(x) + + return self.act(y) + + def _fuse(self): + if not hasattr(self, "conv"): + self.conv = nn.Conv2d(self.ch_in, self.ch_out, 3, 1, padding=1, bias=True) + kernel, bias = self.get_equivalent_kernel_bias() + self.conv.weight.data = kernel + if self.conv.bias is not None: + self.conv.bias.data = bias + # self.__delattr__('conv1') + # self.__delattr__('conv2') + + def get_equivalent_kernel_bias(self): + kernel3x3, bias3x3 = self._fuse_bn_tensor(self.conv1) + kernel1x1, bias1x1 = self._fuse_bn_tensor(self.conv2) + + return kernel3x3 + self._pad_1x1_to_3x3_tensor(kernel1x1), bias3x3 + bias1x1 + + def _pad_1x1_to_3x3_tensor(self, kernel1x1): + if kernel1x1 is None: + return 0 + else: + return F.pad(kernel1x1, [1, 1, 1, 1]) + + def _fuse_bn_tensor(self, branch: ConvNormLayer): + if branch is None: + return 0, 0 + kernel = branch.conv.weight + running_mean = branch.norm.running_mean + running_var = branch.norm.running_var + gamma = branch.norm.weight + beta = branch.norm.bias + eps = branch.norm.eps + assert running_var is not None, "Error: running_var is None" + std = (running_var + eps).sqrt() + t = (gamma / std).reshape(-1, 1, 1, 1) + return kernel * t, beta - running_mean * gamma / std + + +class CSPRepLayer(nn.Module): + def __init__( + self, + in_channels, + out_channels, + num_blocks=3, + expansion=1.0, + bias=False, + ): + super().__init__() + hidden_channels = int(out_channels * expansion) + self.conv1 = ConvNormLayer(in_channels, hidden_channels, 1, 1, bias=bias, act="silu") + self.conv2 = ConvNormLayer(in_channels, hidden_channels, 1, 1, bias=bias, act="silu") + self.bottlenecks = nn.Sequential(*[RepVggBlock(hidden_channels, hidden_channels) for _ in range(num_blocks)]) + if hidden_channels != out_channels: + self.conv3 = ConvNormLayer(hidden_channels, out_channels, 1, 1, bias=bias, act="silu") + else: + self.conv3 = nn.Identity() + + def forward(self, x): + x_1 = self.conv1(x) + x_1 = self.bottlenecks(x_1) + x_2 = self.conv2(x) + return self.conv3(x_1 + x_2) + + +class PositionEmbeddingSine(nn.Module): + """Sinusoidal positional embedding module. + + This is a standard version of the position embedding, similar to the one + used by the 'Attention is all you need' paper, generalized to work on images. + """ + + def __init__( + self, + num_pos_feats: int = 64, + temperature: int = 10000, + scale: float = 2 * math.pi, + eps: float = 1e-6, + offset: float = 0.0, + normalize: bool = False, + ): + """Initialize sinusoidal positional embedding. + + Args: + num_pos_feats: Number of positional features + temperature: Temperature parameter for the embedding + scale: Scale factor for normalized coordinates + eps: Small constant for numerical stability + offset: Offset for coordinate normalization + normalize: Whether to normalize coordinates + """ + super().__init__() + if normalize: + assert isinstance(scale, (float, int)), ( + f"when normalize is set, scale should be provided and in float or int type, found {type(scale)}" + ) + self.num_pos_feats = num_pos_feats + self.temperature = temperature + self.normalize = normalize + self.scale = scale + self.eps = eps + self.offset = offset + + def forward(self, x, mask=None): + """Generate positional embeddings for input tensor. + + Args: + x: Input tensor + mask: Optional mask tensor + + Returns: + Positional embedding tensor + """ + if mask is None: + mask = torch.zeros((x.size(0), x.size(2), x.size(3)), device=x.device, dtype=torch.bool) + not_mask = ~mask + y_embed = not_mask.cumsum(1, dtype=torch.float32) - 1 + x_embed = not_mask.cumsum(2, dtype=torch.float32) - 1 + if self.normalize: + y_embed = (y_embed + self.offset) / (y_embed[:, -1:, :] + self.eps) * self.scale + x_embed = (x_embed + self.offset) / (x_embed[:, :, -1:] + self.eps) * self.scale + + dim_t = torch.arange(self.num_pos_feats, dtype=torch.float32, device=mask.device) + dim_t = self.temperature ** (2 * torch.div(dim_t, 2, rounding_mode="floor") / self.num_pos_feats) + pos_x = x_embed[:, :, :, None] / dim_t + pos_y = y_embed[:, :, :, None] / dim_t + + # use view as mmdet instead of flatten for dynamically exporting to ONNX + B, H, W = mask.size() + pos_x_sin = pos_x[:, :, :, 0::2].sin().view(B, H * W, -1) + pos_x_cos = pos_x[:, :, :, 1::2].cos().view(B, H * W, -1) + pos_y_sin = pos_y[:, :, :, 0::2].sin().view(B, H * W, -1) + pos_y_cos = pos_y[:, :, :, 1::2].cos().view(B, H * W, -1) + pos = torch.cat((pos_y_sin, pos_y_cos, pos_x_sin, pos_x_cos), dim=2) + return pos + + def __repr__(self, _repr_indent=4): + """Return string representation of the module.""" + head = "Positional encoding " + self.__class__.__name__ + body = [ + "num_pos_feats: {}".format(self.num_pos_feats), + "temperature: {}".format(self.temperature), + "normalize: {}".format(self.normalize), + "scale: {}".format(self.scale), + ] + # _repr_indent = 4 + lines = [head] + [" " * _repr_indent + line for line in body] + return "\n".join(lines) + + +class Encoder(nn.Module): + def __init__( + self, + backbone: BaseBackbone, + feat_dim: int, + out_dim: int, + nhead=8, + dim_feedforward=1024, + dropout=0.0, + enc_act="gelu", + use_encoder_idx=[2], + num_encoder_layers=1, + pe_temperature=10000, + expansion=1.0, + depth_mult=1.0, + ): + super().__init__() + + self.backbone = backbone + self.input_shape = sorted(backbone.output_shape().items(), key=lambda x: x[1].stride) # type: ignore + + # starting from "res2" to "res5" + self.in_channels = [v.channels for k, v in self.input_shape] + self.in_strides = [v.stride for k, v in self.input_shape] + self.out_dim = out_dim + self.feat_dim = feat_dim + self.use_encoder_idx = use_encoder_idx + self.num_encoder_layers = num_encoder_layers + self.pe_temperature = pe_temperature + + self.in_features = ["res3", "res4", "res5"] + self.in_channels = self.in_channels[1:] + self.in_strides = self.in_strides[1:] + + # channel projection + self.input_proj = nn.ModuleList() + for in_channel in self.in_channels: # from res3 to res5 + self.input_proj.append( + nn.Sequential( + nn.Conv2d(in_channel, feat_dim, kernel_size=1, bias=False), + nn.BatchNorm2d(feat_dim), + ) + ) + + # encoder transformer + encoder_layer = TransformerEncoderLayer( + feat_dim, + nhead=nhead, + dim_feedforward=dim_feedforward, + dropout=dropout, + activation=enc_act, + ) + + self.encoder = nn.ModuleList( + [TransformerEncoder(copy.deepcopy(encoder_layer), num_encoder_layers) for _ in range(len(use_encoder_idx))] + ) + self.pe_layer = PositionEmbeddingSine(num_pos_feats=feat_dim // 2, temperature=10000, normalize=False) + + # top-down fpn + self.lateral_convs = nn.ModuleList() + self.fpn_blocks = nn.ModuleList() + for _ in range(len(self.in_channels) - 1, 0, -1): + self.lateral_convs.append(ConvNormLayer(feat_dim, feat_dim, 1, 1, act="silu")) + self.fpn_blocks.append( + CSPRepLayer( + feat_dim * 2, + feat_dim, + round(3 * depth_mult), + expansion=expansion, + ) + ) + + # bottom-up pan + self.downsample_convs = nn.ModuleList() + self.pan_blocks = nn.ModuleList() + for _ in range(len(self.in_channels) - 1): + self.downsample_convs.append(ConvNormLayer(feat_dim, feat_dim, 3, 1, act="silu")) + self.pan_blocks.append( + CSPRepLayer( + feat_dim * 2, + feat_dim, + round(3 * depth_mult), + expansion=expansion, + ) + ) + + self.mask_dim = out_dim + self.mask_features = Conv2d( + feat_dim, + out_dim, + kernel_size=3, + stride=1, + padding=1, + ) + nn.init.kaiming_uniform_(self.mask_features.weight, a=1) + if self.mask_features.bias is not None: + nn.init.constant_(self.mask_features.bias, 0) + + @property + def padding_constraints(self) -> Dict[str, int]: + return self.backbone.padding_constraints + + def forward(self, images: torch.Tensor): + features = self.backbone(images) + + feats = [features[f] for f in self.in_features] + assert len(feats) == len(self.in_channels) + proj_feats = [self.input_proj[i](feat) for i, feat in enumerate(feats)] + + # encoder + if self.num_encoder_layers > 0: + for i, enc_ind in enumerate(self.use_encoder_idx): + h, w = proj_feats[enc_ind].shape[2:] + # flatten [B, C, H, W] to [B, HxW, C] + src_flatten = proj_feats[enc_ind].flatten(2).permute(0, 2, 1) + + pos_embed = self.pe_layer(proj_feats[enc_ind]) + + memory = self.encoder[i](src_flatten, pos_embed=pos_embed) + proj_feats[enc_ind] = memory.permute(0, 2, 1).reshape(-1, self.feat_dim, h, w).contiguous() + # print([x.is_contiguous() for x in proj_feats ]) + + # broadcasting and fusion + inner_outs = [proj_feats[-1]] + for idx in range(len(self.in_channels) - 1, 0, -1): # 2, 1 + feat_heigh = inner_outs[0] + feat_low = proj_feats[idx - 1] + feat_heigh = self.lateral_convs[len(self.in_channels) - 1 - idx](feat_heigh) + inner_outs[0] = feat_heigh + upsample_feat = F.interpolate(feat_heigh, size=feat_low.shape[-2:], mode="bilinear") + inner_out = self.fpn_blocks[len(self.in_channels) - 1 - idx](torch.concat([upsample_feat, feat_low], dim=1)) + inner_outs.insert(0, inner_out) + # Inner out is: [[bs, c, h/8, h/8], [bs, c, h/16, h/16], [bs, c, h/32, h/32]] + outs = [inner_outs[0]] + for idx in range(len(self.in_channels) - 1): + feat_low = outs[-1] + feat_height = inner_outs[idx + 1] + downsample_feat = F.interpolate(feat_low, size=feat_height.shape[-2:], mode="bilinear") + downsample_feat = self.downsample_convs[idx](downsample_feat) + out = self.pan_blocks[idx](torch.concat([downsample_feat, feat_height], dim=1)) + outs.append(out) + + return self.mask_features(outs[0]), outs[::-1] + + +class DETRHead(nn.Module): + def __init__( + self, + *, + in_channels: int, + out_dim: int, + num_classes: int, + criterion: nn.Module, + # extra parameters + transformer_predictor: nn.Module, + mask_on=False, + cls_sigmoid=True, + ): + """ + Args: + num_classes: number of classes to predict + loss_weight: loss weight + transformer_predictor: the transformer decoder that makes prediction + """ + super().__init__() + + self.in_channels = in_channels + self.out_dim = out_dim + self.criterion = criterion + self.predictor = transformer_predictor + self.cls_sigmoid = cls_sigmoid + + self.num_classes = num_classes + self.mask_on = mask_on + + def layers(self, features): + _, multi_scale_features = features + predictions = self.predictor(feats=multi_scale_features) + + return predictions + + def forward(self, features, targets: list[DETRTargets] = []): + outputs = self.layers(features) + + loss = None + if targets is not None and len(targets) > 0: + loss = self.losses(outputs, targets) + boxes = outputs["pred_boxes"] + boxes = box_cxcywh_to_xyxy(boxes) + + logits = outputs["pred_logits"] + if self.cls_sigmoid: + logits = F.sigmoid(logits) + else: + logits = F.softmax(logits, -1)[:, :, :-1] + + return (logits, boxes), loss + + def losses(self, predictions, targets: list[DETRTargets]): + losses = self.criterion(predictions, targets) + + return losses + + +class SetCriterion(nn.Module): + """This class computes the loss for DETR. + The process happens in two steps: + 1) we compute hungarian assignment between ground truth boxes and the outputs of the model + 2) we supervise each pair of matched ground-truth / prediction (supervise class and box) + """ + + def __init__( + self, + num_classes: int, + matcher: nn.Module, + weight_dict: dict, + losses: list[str], + eos_coef: float = 0.1, + num_points: int = 0, + oversample_ratio: float = 3.0, + importance_sample_ratio: float = 0.0, + deep_supervision: bool = True, + use_focal: bool = False, # deprecated + loss_class_type: str = "ce_loss", # deprecated + focal_alpha: float = 0.75, + focal_gamma: float = 2.0, + cls_sigmoid: bool = False, + ): + """Create the criterion. + Parameters: + num_classes: number of object categories, omitting the special no-object category + matcher: module able to compute a matching between targets and proposals + weight_dict: dict containing as key the names of the losses and as values their relative weight. + eos_coef: relative classification weight applied to the no-object category + losses: list of all the losses to be applied. See get_loss for list of available losses. + """ + super().__init__() + + self.num_classes = num_classes + self.matcher = matcher + self.weight_dict = weight_dict + self.losses = losses + self.deep_supervision = deep_supervision + + self.eos_coef = eos_coef + empty_weight = torch.ones(self.num_classes + 1) + empty_weight[-1] = self.eos_coef + self.register_buffer("empty_weight", empty_weight) + + # pointwise mask loss parameters + self.num_points = num_points + self.oversample_ratio = oversample_ratio + self.importance_sample_ratio = importance_sample_ratio + + self.loss_class_type = loss_class_type + self.focal_alpha = focal_alpha + self.focal_gamma = focal_gamma + self.cls_sigmoid = cls_sigmoid + + def loss_labels_vfl(self, outputs, targets: list[DETRTargets], indices, num_boxes): + assert "pred_boxes" in outputs + idx = self._get_src_permutation_idx(indices) + + src_boxes = outputs["pred_boxes"][idx] + target_boxes = torch.cat([t.boxes[i] for t, (_, i) in zip(targets, indices)], dim=0) + ious, _ = box_iou(box_cxcywh_to_xyxy(src_boxes), box_cxcywh_to_xyxy(target_boxes)) + ious = torch.diag(ious).detach() + + src_logits = outputs["pred_logits"] + target_classes_o = torch.cat([t.labels[J] for t, (_, J) in zip(targets, indices)]) + target_classes = torch.full( + src_logits.shape[:2], + self.num_classes, + dtype=torch.int64, + device=src_logits.device, + ) + target_classes[idx] = target_classes_o + target = F.one_hot(target_classes, num_classes=self.num_classes + 1)[..., :-1] + + target_score_o = torch.zeros_like(target_classes, dtype=src_logits.dtype) + target_score_o[idx] = ious.to(target_score_o.dtype) + target_score = target_score_o.unsqueeze(-1) * target + + pred_score = F.sigmoid(src_logits).detach() + weight = self.focal_alpha * pred_score.pow(self.focal_gamma) * (1 - target) + target_score + + loss = F.binary_cross_entropy_with_logits(src_logits, target_score, weight=weight, reduction="none") + loss = loss.mean(1).sum() * src_logits.shape[1] / num_boxes + + losses = {"loss_vfl": loss} + # losses["class_error"] = 1 - accuracy(src_logits[idx], target_classes_o)[0] + + return losses + + @torch.no_grad() + def loss_cardinality(self, outputs, targets: list[DETRTargets], indices, num_boxes): + """Compute the cardinality error, ie the absolute error in the number of predicted non-empty boxes + This is not really a loss, it is intended for logging purposes only. It doesn't propagate gradients + """ + pred_logits = outputs["pred_logits"] + device = pred_logits.device + tgt_lengths = torch.as_tensor([len(v.labels) for v in targets], device=device) + # Count the number of predictions that are NOT "no-object" (which is the last class) + card_pred = (pred_logits.argmax(-1) != pred_logits.shape[-1] - 1).sum(1) + card_err = F.l1_loss(card_pred.float(), tgt_lengths.float()) + losses = {"cardinality_error": card_err} + return losses + + def loss_boxes(self, outputs, targets: list[DETRTargets], indices, num_boxes): + """Compute the losses related to the bounding boxes, the L1 regression loss and the GIoU loss + targets dicts must contain the key "boxes" containing a tensor of dim [nb_target_boxes, 4] + The target boxes are expected in format (center_x, center_y, w, h), normalized by the image size. + """ + assert "pred_boxes" in outputs + idx = self._get_src_permutation_idx(indices) + src_boxes = outputs["pred_boxes"][idx] + target_boxes = torch.cat([t.boxes[i] for t, (_, i) in zip(targets, indices)], dim=0) + + losses = {} + + loss_bbox = F.l1_loss(src_boxes, target_boxes, reduction="none") + losses["loss_bbox"] = loss_bbox.sum() / num_boxes + + loss_giou = 1 - torch.diag(generalized_box_iou(box_cxcywh_to_xyxy(src_boxes), box_cxcywh_to_xyxy(target_boxes))) + losses["loss_giou"] = loss_giou.sum() / num_boxes + return losses + + def _get_src_permutation_idx(self, indices): + # permute predictions following indices + batch_idx = torch.cat([torch.full_like(src, i) for i, (src, _) in enumerate(indices)]) + src_idx = torch.cat([src for (src, _) in indices]) + return batch_idx, src_idx + + def _get_tgt_permutation_idx(self, indices): + # permute targets following indices + batch_idx = torch.cat([torch.full_like(tgt, i) for i, (_, tgt) in enumerate(indices)]) + tgt_idx = torch.cat([tgt for (_, tgt) in indices]) + return batch_idx, tgt_idx + + def get_loss(self, loss, outputs, targets: list[DETRTargets], indices, num_masks, **kwargs): + loss_map = { + "cardinality": self.loss_cardinality, + "boxes": self.loss_boxes, + "vfl": self.loss_labels_vfl, + } + assert loss in loss_map, f"do you really want to compute {loss} loss?" + return loss_map[loss](outputs, targets, indices, num_masks, **kwargs) + + def forward(self, outputs, targets: list[DETRTargets]): + """This performs the loss computation. + Parameters: + outputs: dict of tensors, see the output specification of the model for the format + targets: list of dicts, such that len(targets) == batch_size. + The expected keys in each dict depends on the losses applied, see each loss' doc + """ + outputs_without_aux = {k: v for k, v in outputs.items() if k != "aux_outputs"} + + # Retrieve the matching between the outputs of the last layer and the targets + indices = self.matcher(outputs_without_aux, targets) + + # Compute the average number of target boxes accross all nodes, for normalization purposes + num_masks = sum(len(t.labels) for t in targets) + num_masks = torch.as_tensor([num_masks], dtype=torch.float, device=next(iter(outputs.values())).device) + if is_dist_available_and_initialized(): + torch.distributed.all_reduce(num_masks) + num_masks = torch.clamp(num_masks / get_world_size(), min=1).item() + + # Compute all the requested losses + losses = {} + for loss in self.losses: + l_dict = self.get_loss(loss, outputs, targets, indices, num_masks) + for k in list(l_dict.keys()): + if k in self.weight_dict: + l_dict[k] *= self.weight_dict[k] + losses.update(l_dict) + + # In case of auxiliary losses, we repeat this process with the output of each intermediate layer. + if "aux_outputs" in outputs and self.deep_supervision: + for i, aux_outputs in enumerate(outputs["aux_outputs"]): + indices = self.matcher(aux_outputs, targets) + for loss in self.losses: + l_dict = self.get_loss(loss, aux_outputs, targets, indices, num_masks) + for k in list(l_dict.keys()): + if k in self.weight_dict: + l_dict[k] *= self.weight_dict[k] + + l_dict = {k + f"_{i}": v for k, v in l_dict.items()} + + losses.update(l_dict) + + # In case of cdn auxiliary losses. For rtdetr + if "dn_aux_outputs" in outputs: + assert "dn_meta" in outputs, "" + indices = self.get_cdn_matched_indices(outputs["dn_meta"], targets) + num_masks = num_masks * outputs["dn_meta"]["dn_num_group"] + + for i, aux_outputs in enumerate(outputs["dn_aux_outputs"]): + # indices = self.matcher(aux_outputs, targets) + for loss in self.losses: + if loss == "masks": + # Intermediate masks losses are too costly to compute, we ignore them. + continue + l_dict = self.get_loss(loss, aux_outputs, targets, indices, num_masks) + l_dict = {k: l_dict[k] * self.weight_dict[k] for k in l_dict if k in self.weight_dict} + l_dict = {k + f"_dn_{i}": v for k, v in l_dict.items()} + losses.update(l_dict) + + return losses + + def __repr__(self): + head = "Criterion " + self.__class__.__name__ + body = [ + "matcher: {}".format(self.matcher), + "losses: {}".format(self.losses), + "weight_dict: {}".format(self.weight_dict), + "num_classes: {}".format(self.num_classes), + "eos_coef: {}".format(self.eos_coef), + "num_points: {}".format(self.num_points), + "oversample_ratio: {}".format(self.oversample_ratio), + "importance_sample_ratio: {}".format(self.importance_sample_ratio), + ] + _repr_indent = 4 + lines = [head] + [" " * _repr_indent + line for line in body] + return "\n".join(lines) + + @staticmethod + def get_cdn_matched_indices(dn_meta, targets): + """get_cdn_matched_indices""" + dn_positive_idx, dn_num_group = ( + dn_meta["dn_positive_idx"], + dn_meta["dn_num_group"], + ) + num_gts = [len(t["labels"]) for t in targets] + device = targets[0]["labels"].device + + dn_match_indices = [] + for i, num_gt in enumerate(num_gts): + if num_gt > 0: + gt_idx = torch.arange(num_gt, dtype=torch.int64, device=device) + gt_idx = gt_idx.tile(dn_num_group) + assert len(dn_positive_idx[i]) == len(gt_idx) + dn_match_indices.append((dn_positive_idx[i], gt_idx)) + else: + dn_match_indices.append( + ( + torch.zeros(0, dtype=torch.int64, device=device), + torch.zeros(0, dtype=torch.int64, device=device), + ) + ) + + return dn_match_indices + + +class BoxHungarianMatcher(nn.Module): + """This class computes an assignment between the targets and the predictions of the network + + For efficiency reasons, the targets don't include the no_object. Because of this, in general, + there are more predictions than targets. In this case, we do a 1-to-1 matching of the best predictions, + while the others are un-matched (and thus treated as non-objects). + """ + + def __init__( + self, + cost_class: float = 1, + cost_bbox: float = 1, + cost_giou: float = 1, + use_focal_loss=False, + alpha=0.25, + gamma=2.0, + ): + """Creates the matcher + + Params: + cost_class: This is the relative weight of the classification error in the matching cost + cost_bbox: This is the relative weight of the L1 error of the bounding box coordinates in the matching cost + cost_giou: This is the relative weight of the giou loss of the bounding box in the matching cost + """ + super().__init__() + self.cost_class = cost_class + self.cost_bbox = cost_bbox + self.cost_giou = cost_giou + + assert self.cost_class != 0 or self.cost_bbox != 0 or self.cost_giou != 0, "all costs cant be 0" + + self.use_focal_loss = use_focal_loss + self.alpha = alpha + self.gamma = gamma + + @torch.no_grad() + def forward(self, outputs, targets: list[DETRTargets]): + """Performs the matching + + Params: + outputs: This is a dict that contains at least these entries: + "pred_logits": Tensor of dim [batch_size, num_queries, num_classes] with the classification logits + "pred_boxes": Tensor of dim [batch_size, num_queries, 4] with the predicted box coordinates + + targets: This is a list of targets (len(targets) = batch_size), where each target is a RTDETRTargets containing: + "labels": Tensor of dim [num_target_boxes] (where num_target_boxes is the number of ground-truth + objects in the target) containing the class labels + "boxes": Tensor of dim [num_target_boxes, 4] containing the target box coordinates + + Returns: + A list of size batch_size, containing tuples of (index_i, index_j) where: + - index_i is the indices of the selected predictions (in order) + - index_j is the indices of the corresponding selected targets (in order) + For each batch element, it holds: + len(index_i) = len(index_j) = min(num_queries, num_target_boxes) + """ + bs, num_queries = outputs["pred_logits"].shape[:2] + + # We flatten to compute the cost matrices in a batch + if self.use_focal_loss: + out_prob = F.sigmoid(outputs["pred_logits"].flatten(0, 1)) + else: + out_prob = outputs["pred_logits"].flatten(0, 1).softmax(-1) # [batch_size * num_queries, num_classes] + out_bbox = outputs["pred_boxes"].flatten(0, 1) # [batch_size * num_queries, 4] + + # Also concat the target labels and boxes + tgt_ids = torch.cat([v.labels for v in targets]) + tgt_bbox = torch.cat([v.boxes for v in targets]) + + # Compute the classification cost. Contrary to the loss, we don't use the NLL, + # but approximate it in 1 - proba[target class]. + # The 1 is a constant that doesn't change the matching, it can be ommitted. + if self.use_focal_loss: + out_prob = out_prob[:, tgt_ids] + neg_cost_class = (1 - self.alpha) * (out_prob**self.gamma) * (-(1 - out_prob + 1e-8).log()) + pos_cost_class = self.alpha * ((1 - out_prob) ** self.gamma) * (-(out_prob + 1e-8).log()) + cost_class = pos_cost_class - neg_cost_class + else: + cost_class = -out_prob[:, tgt_ids] + + # Compute the L1 cost between boxes + cost_bbox = torch.cdist(out_bbox, tgt_bbox, p=1) + + # Compute the giou cost betwen boxes + cost_giou = -generalized_box_iou(box_cxcywh_to_xyxy(out_bbox), box_cxcywh_to_xyxy(tgt_bbox)) + + # Final cost matrix + C = self.cost_bbox * cost_bbox + self.cost_class * cost_class + self.cost_giou * cost_giou + C = C.view(bs, num_queries, -1).cpu() + # FIXME This linear sum assignment is done on CPU. Can we use GPU? + + sizes = [len(v.boxes) for v in targets] + indices = [linear_sum_assignment(c[i]) for i, c in enumerate(C.split(sizes, -1))] + + return [ + ( + torch.as_tensor(i, dtype=torch.int64), + torch.as_tensor(j, dtype=torch.int64), + ) + for i, j in indices + ] + + def __repr__(self, _repr_indent=4): + head = "Matcher " + self.__class__.__name__ + body = [ + "cost_class: {}".format(self.cost_class), + "cost_bbox: {}".format(self.cost_bbox), + "cost_giou: {}".format(self.cost_giou), + ] + lines = [head] + [" " * _repr_indent + line for line in body] + return "\n".join(lines) + + +def bias_init_with_prob(prior_prob=0.01): + """initialize conv/fc bias value according to a given probability value.""" + bias_init = float(-math.log((1 - prior_prob) / prior_prob)) + return bias_init + + +class MSDeformableAttention(nn.Module): + def __init__( + self, + embed_dim=256, + num_heads=8, + num_levels=4, + num_points=4, + ): + """ + Multi-Scale Deformable Attention Module + """ + super().__init__() + self.embed_dim = embed_dim + self.num_heads = num_heads + self.num_levels = num_levels + self.num_points = num_points + self.total_points = num_heads * num_levels * num_points + + self.head_dim = embed_dim // num_heads + assert self.head_dim * num_heads == self.embed_dim, "embed_dim must be divisible by num_heads" + + self.sampling_offsets = nn.Linear( + embed_dim, + self.total_points * 2, + ) + self.attention_weights = nn.Linear(embed_dim, self.total_points) + self.value_proj = nn.Linear(embed_dim, embed_dim) + self.output_proj = nn.Linear(embed_dim, embed_dim) + + self.ms_deformable_attn_core = ms_deform_attn_core_pytorch + + self._reset_parameters() + + def _reset_parameters(self): + # sampling_offsets + init.constant_(self.sampling_offsets.weight, 0) + thetas = torch.arange(self.num_heads, dtype=torch.float32) * (2.0 * math.pi / self.num_heads) + grid_init = torch.stack([thetas.cos(), thetas.sin()], -1) + grid_init = grid_init / grid_init.abs().max(-1, keepdim=True).values + grid_init = grid_init.reshape(self.num_heads, 1, 1, 2).tile([1, self.num_levels, self.num_points, 1]) + scaling = torch.arange(1, self.num_points + 1, dtype=torch.float32).reshape(1, 1, -1, 1) + grid_init *= scaling + self.sampling_offsets.bias.data[...] = grid_init.flatten() + + # attention_weights + init.constant_(self.attention_weights.weight, 0) + init.constant_(self.attention_weights.bias, 0) + + # proj + init.xavier_uniform_(self.value_proj.weight) + init.constant_(self.value_proj.bias, 0) + init.xavier_uniform_(self.output_proj.weight) + init.constant_(self.output_proj.bias, 0) + + def forward(self, query, reference_points, value, value_spatial_shapes, value_mask=None): + """ + Args: + query (Tensor): [bs, query_length, C] + reference_points (Tensor): [bs, query_length, n_levels, 2], range in [0, 1], top-left (0,0), + bottom-right (1, 1), including padding area + value (Tensor): [bs, value_length, C] + value_spatial_shapes (List): [n_levels, 2], [(H_0, W_0), (H_1, W_1), ..., (H_{L-1}, W_{L-1})] + value_level_start_index (List): [n_levels], [0, H_0*W_0, H_0*W_0+H_1*W_1, ...] + value_mask (Tensor): [bs, value_length], True for non-padding elements, False for padding elements + + Returns: + output (Tensor): [bs, Length_{query}, C] + """ + bs, Len_q = query.shape[:2] + Len_v = value.shape[1] + + value = self.value_proj(value) + if value_mask is not None: + value_mask = value_mask.astype(value.dtype).unsqueeze(-1) + value *= value_mask + value = value.reshape(bs, Len_v, self.num_heads, self.head_dim) + + sampling_offsets = self.sampling_offsets(query).reshape( + bs, Len_q, self.num_heads, self.num_levels, self.num_points, 2 + ) + attention_weights = self.attention_weights(query).reshape( + bs, Len_q, self.num_heads, self.num_levels * self.num_points + ) + attention_weights = F.softmax(attention_weights, dim=-1).reshape( + bs, Len_q, self.num_heads, self.num_levels, self.num_points + ) + + if reference_points.shape[-1] == 2: + offset_normalizer = torch.tensor(value_spatial_shapes) + offset_normalizer = offset_normalizer.flip([1]).reshape(1, 1, 1, self.num_levels, 1, 2) + sampling_locations = ( + reference_points.reshape(bs, Len_q, 1, self.num_levels, 1, 2) + sampling_offsets / offset_normalizer + ) + elif reference_points.shape[-1] == 4: + sampling_locations = ( + reference_points[:, :, None, :, None, :2] + + sampling_offsets / self.num_points * reference_points[:, :, None, :, None, 2:] * 0.5 + ) + else: + raise ValueError( + "Last dim of reference_points must be 2 or 4, but get {} instead.".format(reference_points.shape[-1]) + ) + + output = self.ms_deformable_attn_core(value, value_spatial_shapes, sampling_locations, attention_weights) + + output = self.output_proj(output) + + return output + + +class TransformerDecoderLayer(nn.Module): + def __init__( + self, + d_model=256, + n_head=8, + dropout=0.0, + activation="relu", + dim_feedforward=1024, + n_levels=4, + n_points=4, + ): + super().__init__() + + # self attention + self.self_attn = nn.MultiheadAttention(d_model, n_head, dropout=dropout, batch_first=True) + self.dropout1 = nn.Dropout(dropout) + self.norm1 = nn.LayerNorm(d_model) + + # cross attention + self.cross_attn = MSDeformableAttention(d_model, n_head, n_levels, n_points) + self.dropout2 = nn.Dropout(dropout) + self.norm2 = nn.LayerNorm(d_model) + + # ffn + self.linear1 = nn.Linear(d_model, dim_feedforward) + self.activation = getattr(F, activation) + self.dropout3 = nn.Dropout(dropout) + self.linear2 = nn.Linear(dim_feedforward, d_model) + self.dropout4 = nn.Dropout(dropout) + self.norm3 = nn.LayerNorm(d_model) + + def with_pos_embed(self, tensor, pos): + return tensor if pos is None else tensor + pos + + def forward_ffn(self, tgt): + return self.linear2(self.activation(self.linear1(tgt))) + + def forward( + self, + tgt, + reference_points, + memory, + memory_spatial_shapes, + memory_level_start_index, + attn_mask=None, + memory_mask=None, + query_pos_embed=None, + ): + # self attention + q = k = self.with_pos_embed(tgt, query_pos_embed) + + tgt2, _ = self.self_attn(q, k, value=tgt, attn_mask=attn_mask) + tgt = tgt + self.dropout1(tgt2) + tgt = self.norm1(tgt) + + # cross attention + tgt2 = self.cross_attn( + self.with_pos_embed(tgt, query_pos_embed), + reference_points, + memory, + memory_spatial_shapes, + memory_mask, + ) + tgt = tgt + self.dropout2(tgt2) + tgt = self.norm2(tgt) + + # ffn + tgt2 = self.forward_ffn(tgt) + tgt = tgt + self.dropout4(tgt2) + tgt = self.norm3(tgt) + + return tgt + + +class TransformerDecoder(nn.Module): + def __init__(self, hidden_dim, decoder_layer, num_layers, eval_idx=-1): + super().__init__() + self.layers = nn.ModuleList([copy.deepcopy(decoder_layer) for _ in range(num_layers)]) + self.hidden_dim = hidden_dim + self.num_layers = num_layers + self.eval_idx = eval_idx if eval_idx >= 0 else num_layers + eval_idx + + def forward( + self, + tgt, + ref_points_unact, + memory, + memory_spatial_shapes, + memory_level_start_index, + bbox_head, + score_head, + query_pos_head, + attn_mask=None, + memory_mask=None, + ): + output = tgt + dec_out_bboxes = [] + dec_out_logits = [] + ref_points_detach = F.sigmoid(ref_points_unact) + + ref_points = ref_points_detach # Initialize ref_points before the loop, this is just for linter. + for i, layer in enumerate(self.layers): + ref_points_input = ref_points_detach.unsqueeze(2) + query_pos_embed = query_pos_head(ref_points_detach) + + output = layer( + output, + ref_points_input, + memory, + memory_spatial_shapes, + memory_level_start_index, + attn_mask, + memory_mask, + query_pos_embed, + ) + + inter_ref_bbox = F.sigmoid(bbox_head[i](output) + inverse_sigmoid(ref_points_detach)) + + if self.training: + dec_out_logits.append(score_head[i](output)) + if i == 0: + dec_out_bboxes.append(inter_ref_bbox) + else: + dec_out_bboxes.append(F.sigmoid(bbox_head[i](output) + inverse_sigmoid(ref_points))) + + elif i == self.eval_idx: + dec_out_logits.append(score_head[i](output)) + dec_out_bboxes.append(inter_ref_bbox) + break + + ref_points = inter_ref_bbox + ref_points_detach = inter_ref_bbox.detach() if self.training else inter_ref_bbox + + return torch.stack(dec_out_bboxes), torch.stack(dec_out_logits) + + +class TransformerPredictor(nn.Module): + def __init__( + self, + in_channels, + out_dim, # not used since no mask_classification yet + mask_on=True, + *, + num_classes: int, + sigmoid: bool = True, + hidden_dim: int, + num_queries: int = 300, + nhead: int = 8, + dec_layers: int = 6, + dim_feedforward: int = 1024, + position_embed_type: str = "sine", + num_scales: int = 3, + num_decoder_points: int = 4, + eval_idx: int = -1, + ): + super().__init__() + assert position_embed_type in [ + "sine", + "learned", + ], f"ValueError: position_embed_type not supported {position_embed_type}!" + + self.in_channels = in_channels + self.out_dim = out_dim + self.mask_on = mask_on + self.sigmoid = sigmoid + if self.mask_on: + raise NotImplementedError("mask classification not supported yet!") + + self.hidden_dim = hidden_dim + self.nhead = nhead + self.num_levels = num_scales + assert self.num_levels == 3, "num_scales should equal to 3" + num_classes = num_classes if self.sigmoid else num_classes + 1 + self.num_classes = num_classes + self.num_queries = num_queries + self.dec_layers = dec_layers + + self.eps = 1e-2 + # !fixme: generalize to any feat stride. + self.feat_strides = [32, 16, 8] + + # backbone feature projection + self.input_proj = self._build_input_proj_layer([in_channels] * num_scales) + + # Transformer module + decoder_layer = TransformerDecoderLayer( + hidden_dim, + nhead, + dim_feedforward=dim_feedforward, + dropout=0.0, + activation="relu", + n_levels=num_scales, + n_points=num_decoder_points, + ) + self.decoder = TransformerDecoder(hidden_dim, decoder_layer, dec_layers, eval_idx) + + # decoder embedding + self.query_pos_head = MLP(4, 2 * hidden_dim, hidden_dim, num_layers=2) + + # encoder head + self.enc_output = nn.Sequential( + nn.Linear(hidden_dim, hidden_dim), + nn.LayerNorm( + hidden_dim, + ), + ) + self.enc_score_classifier = nn.Linear(hidden_dim, num_classes) + self.enc_bbox_classifier = MLP(hidden_dim, hidden_dim, 4, num_layers=3) + + # decoder head + self.dec_score_classifier = nn.ModuleList([nn.Linear(hidden_dim, num_classes) for _ in range(dec_layers)]) + self.dec_bbox_classifier = nn.ModuleList( + [MLP(hidden_dim, hidden_dim, 4, num_layers=3) for _ in range(dec_layers)] + ) + self.spatial_shapes = [[int(640 / s), int(640 / s)] for s in self.feat_strides] + + self.anchors, self.valid_mask = self._generate_anchors(self.spatial_shapes) + self._reset_parameters(num_classes=num_classes + 1) + + def _reset_parameters(self, num_classes): + bias = bias_init_with_prob(1 / num_classes) + + init.constant_(self.enc_score_classifier.bias, bias) + init.constant_(self.enc_bbox_classifier.layers[-1].weight, 0) + init.constant_(self.enc_bbox_classifier.layers[-1].bias, 0) + + for cls_, reg_ in zip(self.dec_score_classifier, self.dec_bbox_classifier): + init.constant_(cls_.bias, bias) + init.constant_(reg_.layers[-1].weight, 0) + init.constant_(reg_.layers[-1].bias, 0) + + init.xavier_uniform_(self.enc_output[0].weight) + init.xavier_uniform_(self.query_pos_head.layers[0].weight) + init.xavier_uniform_(self.query_pos_head.layers[1].weight) + + def _build_input_proj_layer(self, feat_channels): + input_proj = nn.ModuleList() + for in_channels in feat_channels: + input_proj.append( + nn.Sequential( + OrderedDict( + [ + ( + "conv", + nn.Conv2d(in_channels, self.hidden_dim, 1, bias=False), + ), + ( + "norm", + nn.BatchNorm2d( + self.hidden_dim, + ), + ), + ] + ) + ) + ) + return input_proj + + def _get_encoder_input(self, feats): + # get projection features + proj_feats = [self.input_proj[i](feat) for i, feat in enumerate(feats)] + + # get encoder inputs + feat_flatten = [] + spatial_shapes = [] + level_start_index = [ + 0, + ] + for i, feat in enumerate(proj_feats): + _, _, h, w = feat.shape + # [b, c, h, w] -> [b, h*w, c] + feat_flatten.append(feat.flatten(2).permute(0, 2, 1)) + # [num_levels, 2] + spatial_shapes.append([h, w]) + # [l], start index of each level + level_start_index.append(h * w + level_start_index[-1]) + + # [b, l, c] + feat_flatten = torch.concat(feat_flatten, 1) + level_start_index.pop() + return (feat_flatten, spatial_shapes, level_start_index) + + def _generate_anchors(self, spatial_shapes, grid_size=0.05, dtype=torch.float32, device="cpu"): + anchors = [] + for lvl, (h, w) in enumerate(spatial_shapes): + grid_y, grid_x = torch.meshgrid( # 40x40 -> 00000, 11111, 2222, 3333, ..., + torch.arange(end=h, dtype=dtype), + torch.arange(end=w, dtype=dtype), + indexing="ij", + ) + grid_xy = torch.stack([grid_x, grid_y], -1) # index matrix # 40x40x2 e.g. grid_xy[i,j] = [j, i] + valid_WH = torch.tensor([w, h]).to(dtype) + grid_xy = (grid_xy.unsqueeze(0) + 0.5) / valid_WH # normalized coords # 1x40x40x2 + # reverse the order of level to match the order of spatial_shapes + wh = torch.ones_like(grid_xy) * grid_size * (2.0 ** (2 - lvl)) + anchors.append(torch.concat([grid_xy, wh], -1).reshape(-1, h * w, 4)) + + anchors = torch.concat(anchors, 1).to(device) + valid_mask = ((anchors > self.eps) * (anchors < 1 - self.eps)).all(-1, keepdim=True) + anchors = torch.log(anchors / (1 - anchors)) # This is the inverse of sigmoid. + anchors = torch.where(valid_mask, anchors, 0.0) + + return anchors, valid_mask + + def _get_decoder_input(self, memory, spatial_shapes): + # prepare input for decoder + # with torch.no_grad(): + if self.spatial_shapes is None or self.spatial_shapes != spatial_shapes: + anchors, valid_mask = self._generate_anchors(spatial_shapes, device=memory.device) + self.anchors = anchors + self.valid_mask = valid_mask + self.spatial_shapes = spatial_shapes + else: + anchors, valid_mask = self.anchors.to(memory.device), self.valid_mask.to(memory.device) + + memory = valid_mask.to(memory.dtype) * memory # type: ignore + + output_memory = self.enc_output(memory) + + enc_outputs_class = self.enc_score_classifier(output_memory) + enc_outputs_coord_unact = self.enc_bbox_classifier(output_memory) + anchors + + if self.sigmoid: + scores = enc_outputs_class.max(-1).values + else: + scores = F.softmax(enc_outputs_class, dim=-1)[:, :, :-1].max(-1).values + + _, topk_ind = torch.topk(scores, self.num_queries, dim=1) + + reference_points_unact = enc_outputs_coord_unact.gather( + dim=1, + index=topk_ind.unsqueeze(-1).repeat(1, 1, enc_outputs_coord_unact.shape[-1]), + ) + + enc_topk_bboxes = F.sigmoid(reference_points_unact) + + enc_topk_logits = enc_outputs_class.gather( + dim=1, + index=topk_ind.unsqueeze(-1).repeat(1, 1, enc_outputs_class.shape[-1]), + ) + + # extract region features + target = output_memory.gather(dim=1, index=topk_ind.unsqueeze(-1).repeat(1, 1, output_memory.shape[-1])) + target = target.detach() + + return target, reference_points_unact.detach(), enc_topk_bboxes, enc_topk_logits + + def forward(self, feats): + # input projection and embedding + (memory, spatial_shapes, level_start_index) = self._get_encoder_input(feats) + + ( + target, + init_ref_points_unact, + enc_topk_bboxes, + enc_topk_logits, + ) = self._get_decoder_input(memory, spatial_shapes) + + # decoder + out_bboxes, out_logits = self.decoder( + target, + init_ref_points_unact, + memory, + spatial_shapes, + level_start_index, + self.dec_bbox_classifier, + self.dec_score_classifier, + self.query_pos_head, + ) + + out = {"pred_logits": out_logits[-1], "pred_boxes": out_bboxes[-1]} + + if self.training: + out["aux_outputs"] = self._set_aux_loss(out_logits[:-1], out_bboxes[:-1]) + out["aux_outputs"].extend(self._set_aux_loss([enc_topk_logits], [enc_topk_bboxes])) + + return out + + @torch.jit.unused + def _set_aux_loss(self, outputs_class, outputs_coord): + # this is a workaround to make torchscript happy, as torchscript + # doesn't support dictionary with non-homogeneous values, such + # as a dict having both a Tensor and a list. + return [{"pred_logits": a, "pred_boxes": b} for a, b in zip(outputs_class, outputs_coord)] + + +class FAIDetr(BaseModelNN): + def __init__(self, config: DETRConfig): + super().__init__(config) + self._export = False + self.config = config + + backbone = load_backbone(self.config.backbone_config) + + self.pixel_decoder = Encoder( + backbone=backbone, + feat_dim=self.config.pixel_decoder_feat_dim, + out_dim=self.config.pixel_decoder_out_dim, + expansion=self.config.pixel_decoder_expansion, + dropout=self.config.pixel_decoder_dropout, + nhead=self.config.pixel_decoder_nhead, + dim_feedforward=self.config.pixel_decoder_dim_feedforward, + num_encoder_layers=self.config.pixel_decoder_num_encoder_layers, + ) + self.head = DETRHead( + in_channels=self.config.transformer_predictor_out_dim, + out_dim=self.config.head_out_dim, + num_classes=self.config.num_classes, + criterion=SetCriterion( + num_classes=self.config.num_classes, + matcher=BoxHungarianMatcher( + cost_class=self.config.matcher_cost_class, + cost_bbox=self.config.matcher_cost_bbox, + cost_giou=self.config.matcher_cost_giou, + use_focal_loss=self.config.matcher_use_focal_loss, + alpha=self.config.matcher_alpha, + gamma=self.config.matcher_gamma, + ), + weight_dict={ + "loss_vfl": self.config.weight_dict_loss_vfl, + "loss_bbox": self.config.weight_dict_loss_bbox, + "loss_giou": self.config.weight_dict_loss_giou, + }, + losses=self.config.criterion_losses, + eos_coef=self.config.criterion_eos_coef, + num_points=self.config.criterion_num_points, + focal_alpha=self.config.criterion_focal_alpha, + focal_gamma=self.config.criterion_focal_gamma, + ), + transformer_predictor=TransformerPredictor( + in_channels=self.config.pixel_decoder_out_dim, + out_dim=self.config.transformer_predictor_out_dim, + num_classes=self.config.num_classes, + hidden_dim=self.config.transformer_predictor_hidden_dim, + mask_on=False, + sigmoid=True, + num_queries=self.config.num_queries, + nhead=self.config.transformer_predictor_nhead, + dec_layers=self.config.transformer_predictor_dec_layers, + dim_feedforward=self.config.transformer_predictor_dim_feedforward, + ), + mask_on=False, + cls_sigmoid=True, + ) + self.register_buffer("pixel_mean", torch.Tensor(self.config.pixel_mean).view(-1, 1, 1), False) + self.register_buffer("pixel_std", torch.Tensor(self.config.pixel_std).view(-1, 1, 1), False) + self.size_divisibility = self.config.size_divisibility + self.num_classes = self.config.num_classes + + @property + def device(self): + return self.pixel_mean.device + + @property + def dtype(self): + return self.pixel_mean.dtype + + def forward( + self, + images: torch.Tensor, + targets: list[DETRTargets] = [], + ) -> DETRModelOutput: + images = (images - self.pixel_mean) / self.pixel_std # type: ignore + + features = self.pixel_decoder(images) + outputs, losses = self.head(features, targets) + + if self.training: + assert targets is not None and len(targets) > 0, "targets should not be None or empty - training mode" + return DETRModelOutput(logits=torch.zeros(0, 0, 0), boxes=torch.zeros(0, 0, 4), loss=losses) + + return DETRModelOutput(logits=outputs[0], boxes=outputs[1], loss=None) diff --git a/focoos/models/fai_detr/ports.py b/focoos/models/fai_detr/ports.py new file mode 100644 index 00000000..8e3d926c --- /dev/null +++ b/focoos/models/fai_detr/ports.py @@ -0,0 +1,19 @@ +from dataclasses import dataclass +from typing import Optional + +import torch + +from focoos.ports import ModelOutput + + +@dataclass +class DETRModelOutput(ModelOutput): + boxes: torch.Tensor # [N, num_queries, 4], XYXY normalized to [0, 1] + logits: torch.Tensor # [N, num_queries, num_classes] + loss: Optional[dict] + + +@dataclass +class DETRTargets: + labels: torch.Tensor + boxes: torch.Tensor diff --git a/focoos/models/fai_detr/processor.py b/focoos/models/fai_detr/processor.py new file mode 100644 index 00000000..52121c37 --- /dev/null +++ b/focoos/models/fai_detr/processor.py @@ -0,0 +1,244 @@ +from typing import Optional, Union + +import numpy as np +import torch +from PIL import Image + +from focoos.models.fai_detr.config import DETRConfig +from focoos.models.fai_detr.ports import DETRModelOutput, DETRTargets +from focoos.ports import DatasetEntry, DynamicAxes, FocoosDet, FocoosDetections +from focoos.processor.base_processor import Processor +from focoos.structures import Boxes, ImageList, Instances +from focoos.utils.box import box_xyxy_to_cxcywh +from focoos.utils.logger import get_logger + +logger = get_logger("DETRProcessor") + + +# perhaps should rename to "resize_instance" +def detector_postprocess( + results: Instances, + output_height: int, + output_width: int, +): + """ + Resize the output instances. + The input images are often resized when entering an object detector. + As a result, we often need the outputs of the detector in a different + resolution from its inputs. + + This function will resize the raw outputs of an R-CNN detector + to produce outputs according to the desired output resolution. + + Args: + results (Instances): the raw outputs from the detector. + `results.image_size` contains the input image resolution the detector sees. + This object might be modified in-place. + output_height, output_width: the desired output resolution. + Returns: + Instances: the resized output from the model, based on the output resolution + """ + new_size = (output_height, output_width) + output_width_tmp = output_width + output_height_tmp = output_height + + scale_x, scale_y = ( + output_width_tmp / results.image_size[1], + output_height_tmp / results.image_size[0], + ) + results = Instances(new_size, boxes=results.boxes, scores=results.scores, classes=results.classes) + + assert results.boxes is not None, "Predictions must contain boxes!" + results.boxes.scale(scale_x, scale_y) + results.boxes.clip(results.image_size) + + results = results[results.boxes.nonempty()] # type: ignore + + return results + + +class DETRProcessor(Processor): + def __init__(self, config: DETRConfig): + super().__init__(config) + self.top_k = config.top_k + self.threshold = config.threshold + self.resolution = config.resolution + + def preprocess( + self, + inputs: Union[ + torch.Tensor, + np.ndarray, + Image.Image, + list[Image.Image], + list[np.ndarray], + list[torch.Tensor], + list[DatasetEntry], + ], + device: torch.device, + dtype: torch.dtype = torch.float32, + image_size: Optional[int] = None, + ) -> tuple[torch.Tensor, list[DETRTargets]]: + targets = [] + if isinstance(inputs, list) and len(inputs) > 0 and isinstance(inputs[0], DatasetEntry): + images = [x.image.to(device) for x in inputs] # type: ignore + images = ImageList.from_tensors( + tensors=images, + ) + images_torch = images.tensor + if self.training: + # mask classification target + gt_instances = [x.instances.to(device) for x in inputs] # type: ignore + h, w = images.tensor.shape[-2:] + targets = [] + for targets_per_image in gt_instances: + assert targets_per_image.boxes is not None and targets_per_image.classes is not None, ( + "boxes and classes are required for training" + ) + image_size_xyxy = torch.as_tensor([w, h, w, h], dtype=torch.float, device=device) + gt_classes = targets_per_image.classes + gt_boxes = targets_per_image.boxes.tensor / image_size_xyxy + gt_boxes = box_xyxy_to_cxcywh(gt_boxes) + targets.append(DETRTargets(labels=gt_classes, boxes=gt_boxes)) + else: + if self.training: + raise ValueError("During training, inputs should be a list of DetectionDatasetDict") + images_torch = self.get_tensors(inputs).to(device, dtype=dtype) # type: ignore + if image_size is not None: + images_torch = torch.nn.functional.interpolate( + images_torch, size=(image_size, image_size), mode="bilinear", align_corners=False + ) + return images_torch, targets + + def eval_postprocess( + self, + output: DETRModelOutput, + batched_inputs: list[DatasetEntry], + top_k: Optional[int] = None, + ) -> list[dict[str, Instances]]: + top_k = top_k or self.top_k + results = [] + box_cls, box_pred = output.logits, output.boxes + batch_size = box_cls.shape[0] + num_classes = box_cls.shape[-1] + + for i in range(batch_size): + # Process results directly within the loop + scores, labels, processed_box_pred = self._get_predictions(box_cls[i], box_pred[i], top_k, num_classes) + + boxes = Boxes(processed_box_pred) + result = Instances(image_size=(1, 1), boxes=boxes, scores=scores, classes=labels) + result = detector_postprocess( + result, output_height=batched_inputs[i].height or 1, output_width=batched_inputs[i].width or 1 + ) + results.append({"instances": result}) + + return results + + def _get_predictions(self, scores, boxes, top_k, num_classes) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]: + scores, index = torch.topk(scores.flatten(0), top_k, dim=-1) + labels = index % num_classes + index = index // num_classes + box_pred = boxes.gather(dim=0, index=index.unsqueeze(-1).repeat(1, boxes.shape[-1])) + return scores, labels, box_pred + + def postprocess( + self, + output: DETRModelOutput, + inputs: Union[ + torch.Tensor, + np.ndarray, + Image.Image, + list[Image.Image], + list[np.ndarray], + list[torch.Tensor], + ], + class_names: list[str] = [], + top_k: Optional[int] = None, + threshold: Optional[float] = None, + ) -> list[FocoosDetections]: + # Extract image sizes from inputs + + top_k = top_k or self.top_k + threshold = threshold or self.threshold + + image_sizes = self.get_image_sizes(inputs) + + results = [] + batch_size = output.boxes.shape[0] + num_classes = output.logits.shape[-1] + + assert len(image_sizes) == batch_size, ( + f"Expected image sizes {len(image_sizes)} to match batch size {batch_size}" + ) + + for i in range(batch_size): + # Process results directly within the loop + scores, labels, box_pred = self._get_predictions(output.logits[i], output.boxes[i], top_k, num_classes) + + # Apply threshold to filter out low-confidence predictions + mask = scores > threshold + box_pred = box_pred[mask] + scores = scores[mask] + labels = labels[mask] + + # Multiply boxes by image size + box_pred[:, 0::2] = box_pred[:, 0::2] * image_sizes[i][1] + box_pred[:, 1::2] = box_pred[:, 1::2] * image_sizes[i][0] + # Convert box coordinates to integers for pixel-precise bounding boxes + box_pred = box_pred.round().to(torch.int32) + + # Convert tensor outputs to Python lists of floats + py_box_pred = box_pred.detach().cpu().tolist() + py_scores = scores.detach().cpu().tolist() + py_labels = labels.detach().cpu().tolist() + results.append( + FocoosDetections( + detections=[ + FocoosDet( + bbox=py_bp, + conf=py_s, + cls_id=py_l, + label=class_names[py_l] if class_names else None, + ) + for py_bp, py_s, py_l in zip(py_box_pred, py_scores, py_labels) + ] + ) + ) + + return results + + def export_postprocess( + self, + output: Union[list[torch.Tensor], list[np.ndarray]], + inputs: Union[ + torch.Tensor, + np.ndarray, + list[np.ndarray], + list[torch.Tensor], + ], + class_names: list[str] = [], + top_k: Optional[int] = None, + threshold: Optional[float] = None, + ) -> list[FocoosDetections]: + boxes = output[0] + logits = output[1] + if isinstance(boxes, np.ndarray): + boxes = torch.from_numpy(boxes) + if isinstance(logits, np.ndarray): + logits = torch.from_numpy(logits) + model_output = DETRModelOutput(boxes=boxes, logits=logits, loss=None) + top_k = 300 if top_k is None else top_k + threshold = 0.5 if threshold is None else threshold + return self.postprocess(model_output, inputs, class_names, top_k, threshold) + + def get_dynamic_axes(self) -> DynamicAxes: + return DynamicAxes( + input_names=["images"], + output_names=["boxes", "logits"], + dynamic_axes={ + "images": {0: "batch", 2: "height", 3: "width"}, + "boxes": {0: "batch"}, + "logits": {0: "batch"}, + }, + ) diff --git a/focoos/models/fai_mf/__init__.py b/focoos/models/fai_mf/__init__.py new file mode 100644 index 00000000..c1dcb9d9 --- /dev/null +++ b/focoos/models/fai_mf/__init__.py @@ -0,0 +1,25 @@ +def _register(): + from focoos.model_manager import ConfigManager, ModelManager + from focoos.ports import ModelFamily + from focoos.processor import ProcessorManager + + def load_model(): + # Questa importazione avviene SOLO quando load_rtdetr_model viene chiamata + from focoos.models.fai_mf.modelling import FAIMaskFormer + + return FAIMaskFormer + + def load_config(): + from focoos.models.fai_mf.config import MaskFormerConfig + + return MaskFormerConfig + + def load_processor(): + from focoos.models.fai_mf.processor import MaskFormerProcessor + + return MaskFormerProcessor + + # Qui registriamo solo la funzione load_rtdetr_model, NON viene eseguita + ModelManager.register_model(ModelFamily.MASKFORMER, load_model) + ConfigManager.register_config(ModelFamily.MASKFORMER, load_config) + ProcessorManager.register_processor(ModelFamily.MASKFORMER, load_processor) diff --git a/focoos/models/fai_mf/config.py b/focoos/models/fai_mf/config.py new file mode 100644 index 00000000..d286e251 --- /dev/null +++ b/focoos/models/fai_mf/config.py @@ -0,0 +1,60 @@ +from dataclasses import dataclass, field +from typing import List, Literal + +from focoos.nn.backbone.base import BackboneConfig +from focoos.ports import ModelConfig + +PostprocessingType = Literal["semantic", "instance"] + + +@dataclass +class MaskFormerConfig(ModelConfig): + backbone_config: BackboneConfig + num_classes: int + + num_queries: int = 100 + resolution: int = 640 + + # Image detector configuration + pixel_mean: List[float] = field(default_factory=lambda: [123.675, 116.28, 103.53]) + pixel_std: List[float] = field(default_factory=lambda: [58.395, 57.12, 57.375]) + size_divisibility: int = 0 + + # Sizing configuration + pixel_decoder_out_dim: int = 256 + pixel_decoder_feat_dim: int = 256 + pixel_decoder_transformer_layers: int = 0 + pixel_decoder_transformer_dropout: float = 0.0 + pixel_decoder_transformer_nheads: int = 8 + pixel_decoder_transformer_dim_feedforward: int = 1024 + + # Transformer decoder + transformer_predictor_out_dim: int = 256 + transformer_predictor_hidden_dim: int = 256 + transformer_predictor_dec_layers: int = 6 + transformer_predictor_dim_feedforward: int = 1024 + # Head configuration + head_out_dim: int = 256 + cls_sigmoid: bool = False + + # Inference configuration + # Options: "semantic", "instance", "panoptic" + postprocessing_type: PostprocessingType = "semantic" + mask_threshold: float = 0.5 + predict_all_pixels: bool = False + use_mask_score: bool = False + threshold: float = 0.5 + top_k: int = 100 + + # Loss configuration + criterion_deep_supervision: bool = True + criterion_eos_coef: float = 0.1 + criterion_num_points: int = 12544 + + weight_dict_loss_dice: int = 5 + weight_dict_loss_mask: int = 5 + weight_dict_loss_ce: int = 2 + + matcher_cost_class: int = 2 + matcher_cost_mask: int = 5 + matcher_cost_dice: int = 5 diff --git a/focoos/models/fai_mf/loss.py b/focoos/models/fai_mf/loss.py new file mode 100644 index 00000000..75cddc25 --- /dev/null +++ b/focoos/models/fai_mf/loss.py @@ -0,0 +1,756 @@ +# Copyright (c) FocoosAI SRL. All rights reserved. +import warnings +from typing import List, Optional + +import torch +import torch.nn.functional as F +import torchvision +from scipy.optimize import linear_sum_assignment +from torch import Tensor, autocast, nn + +from focoos.models.fai_mf.ports import MaskFormerTargets +from focoos.nn.layers.point_rend import get_uncertain_point_coords_with_randomness, point_sample +from focoos.utils.distributed.comm import get_world_size +from focoos.utils.distributed.dist import is_dist_available_and_initialized + +""" +Shape shorthand in this module: + + N: minibatch dimension size, i.e. the number of RoIs for instance segmenation or the + number of images for semantic segmenation. + R: number of ROIs, combined over all images, in the minibatch + P: number of points +""" + + +def calculate_uncertainty(logits): + """ + We estimate uncerainty as L1 distance between 0.0 and the logit prediction in 'logits' for the + foreground class in `classes`. THIS IS IMPLICLTY BASED ON SIGMOID ACTIVATION! + Args: + logits (Tensor): A tensor of shape (R, 1, ...) for class-specific or + class-agnostic, where R is the total number of predicted masks in all images and C is + the number of foreground classes. The values are logits. + Returns: + scores (Tensor): A tensor of shape (R, 1, ...) that contains uncertainty scores with + the most uncertain locations having the highest uncertainty score. + """ + assert logits.shape[1] == 1 + gt_class_logits = logits.clone() + return -(torch.abs(gt_class_logits)) + + +def _max_by_axis(the_list): + # type: (List[List[int]]) -> List[int] + maxes = the_list[0] + for sublist in the_list[1:]: + for index, item in enumerate(sublist): + maxes[index] = max(maxes[index], item) + return maxes + + +class NestedTensor: + def __init__(self, tensors, mask: Optional[Tensor]): + self.tensors = tensors + self.mask = mask + + def to(self, device): + cast_tensor = self.tensors.to(device) + mask = self.mask + if mask is not None: + assert mask is not None + cast_mask = mask.to(device) + else: + cast_mask = None + return NestedTensor(cast_tensor, cast_mask) + + def decompose(self): + return self.tensors, self.mask + + def __repr__(self): + return str(self.tensors) + + +def nested_tensor_from_tensor_list(tensor_list: List[Tensor]): + # TODO make this more general + if tensor_list[0].ndim == 3: + if torchvision._is_tracing(): + # nested_tensor_from_tensor_list() does not export well to ONNX + # call _onnx_nested_tensor_from_tensor_list() instead + return _onnx_nested_tensor_from_tensor_list(tensor_list) + + # TODO make it support different-sized images + max_size = _max_by_axis([list(img.shape) for img in tensor_list]) + # min_size = tuple(min(s) for s in zip(*[img.shape for img in tensor_list])) + batch_shape = [len(tensor_list)] + max_size + b, c, h, w = batch_shape + dtype = tensor_list[0].dtype + device = tensor_list[0].device + tensor = torch.zeros(batch_shape, dtype=dtype, device=device) + mask = torch.ones((b, h, w), dtype=torch.bool, device=device) + for img, pad_img, m in zip(tensor_list, tensor, mask): + pad_img[: img.shape[0], : img.shape[1], : img.shape[2]].copy_(img) + m[: img.shape[1], : img.shape[2]] = False + else: + raise ValueError("not supported") + return NestedTensor(tensor, mask) + + +# _onnx_nested_tensor_from_tensor_list() is an implementation of +# nested_tensor_from_tensor_list() that is supported by ONNX tracing. +@torch.jit.unused +def _onnx_nested_tensor_from_tensor_list(tensor_list: List[Tensor]) -> NestedTensor: + max_size = [] + for i in range(tensor_list[0].dim()): + max_size_i = torch.max(torch.stack([img.shape[i] for img in tensor_list]).to(torch.float32)).to(torch.int64) + max_size.append(max_size_i) + max_size = tuple(max_size) + + # work around for + # pad_img[: img.shape[0], : img.shape[1], : img.shape[2]].copy_(img) + # m[: img.shape[1], :img.shape[2]] = False + # which is not yet supported in onnx + padded_imgs = [] + padded_masks = [] + for img in tensor_list: + padding = [(s1 - s2) for s1, s2 in zip(max_size, tuple(img.shape))] + padded_img = torch.nn.functional.pad(img, (0, padding[2], 0, padding[1], 0, padding[0])) + padded_imgs.append(padded_img) + + m = torch.zeros_like(img[0], dtype=torch.int, device=img.device) + padded_mask = torch.nn.functional.pad(m, (0, padding[2], 0, padding[1]), "constant", 1) + padded_masks.append(padded_mask.to(torch.bool)) + + tensor = torch.stack(padded_imgs) + mask = torch.stack(padded_masks) + + return NestedTensor(tensor, mask=mask) + + +def softmax_dice_loss( + inputs: torch.Tensor, + targets: torch.Tensor, + num_masks: float, +): + """ + Compute the DICE loss, similar to generalized IOU for masks + Args: + inputs: A float tensor of arbitrary shape. + The predictions for each example. + targets: A float tensor with the same shape as inputs. Stores the binary + classification label for each element in inputs + (0 for the negative class and 1 for the positive class). + """ + inputs = inputs.flatten(1) + numerator = 2 * (inputs * targets).sum(-1) + 1.0 + denominator = inputs.sum(-1) + targets.sum(-1) + 1.0 + loss = 1 - (numerator + 1) / (denominator + 1) + return loss.sum() / num_masks + + +def softmax_ce_loss( + inputs: torch.Tensor, + targets: torch.Tensor, + num_masks: float, +): + # loss = F.binary_cross_entropy_with_logits(inputs, targets, reduction="none") + loss = -inputs * targets + + return loss.mean(1).sum() / num_masks + + +softmax_dice_loss_jit = torch.jit.script(softmax_dice_loss) # type: torch.jit.ScriptModule + +softmax_ce_loss_jit = torch.jit.script(softmax_ce_loss) # type: torch.jit.ScriptModule + + +def dice_loss( + inputs: torch.Tensor, + targets: torch.Tensor, + num_masks: float, +): + """ + Compute the DICE loss, similar to generalized IOU for masks + Args: + inputs: A float tensor of arbitrary shape. + The predictions for each example. + targets: A float tensor with the same shape as inputs. Stores the binary + classification label for each element in inputs + (0 for the negative class and 1 for the positive class). + """ + inputs = inputs.sigmoid() + inputs = inputs.flatten(1) + numerator = 2 * (inputs * targets).sum(-1) + denominator = inputs.sum(-1) + targets.sum(-1) + loss = 1 - (numerator + 1) / (denominator + 1) + return loss.sum() / num_masks + + +dice_loss_jit = torch.jit.script(dice_loss) # type: torch.jit.ScriptModule + + +def sigmoid_ce_loss( + inputs: torch.Tensor, + targets: torch.Tensor, + num_masks: float, +): + """ + Args: + inputs: A float tensor of arbitrary shape. + The predictions for each example. + targets: A float tensor with the same shape as inputs. Stores the binary + classification label for each element in inputs + (0 for the negative class and 1 for the positive class). + Returns: + Loss tensor + """ + loss = F.binary_cross_entropy_with_logits(inputs, targets, reduction="none") + + return loss.mean(1).sum() / num_masks + + +sigmoid_ce_loss_jit = torch.jit.script(sigmoid_ce_loss) # type: torch.jit.ScriptModule + + +def focal_loss( + inputs: torch.Tensor, + targets: torch.Tensor, + alpha: float = 10, + gamma: float = 2, + reduction: str = "mean", + ignore_index: int = 255, +) -> torch.Tensor: + """ + Loss used in RetinaNet for dense detection: https://arxiv.org/abs/1708.02002. + Args: + inputs: A float tensor of arbitrary shape. + The predictions for each example. + targets: A float tensor with the same shape as inputs. Stores the binary + classification label for each element in inputs + (0 for the negative class and 1 for the positive class). + alpha: (optional) Weighting factor in range (0,1) to balance + positive vs negative examples. Default = -1 (no weighting). + gamma: Exponent of the modulating factor (1 - p_t) to + balance easy vs hard examples. + reduction: 'none' | 'mean' | 'sum' + 'none': No reduction will be applied to the output. + 'mean': The output will be averaged. + 'sum': The output will be summed. + ignore_index: + Returns: + Loss tensor with the reduction option applied. + """ + ce_loss = F.cross_entropy(inputs, targets, reduction="none", ignore_index=ignore_index) + p_t = torch.exp(-ce_loss) + loss = ce_loss * ((1 - p_t) ** gamma) + + if alpha >= 0: + loss = alpha * loss + + if reduction == "mean": + loss = loss.mean() + elif reduction == "sum": + loss = loss.sum() + + return loss + + +focal_loss_jit = torch.jit.script(focal_loss) # type: torch.jit.ScriptModule + + +def batch_dice_loss(inputs: torch.Tensor, targets: torch.Tensor): + """ + Compute the DICE loss, similar to generalized IOU for masks + Args: + inputs: A float tensor of arbitrary shape. + The predictions for each example. + targets: A float tensor with the same shape as inputs. Stores the binary + classification label for each element in inputs + (0 for the negative class and 1 for the positive class). + """ + inputs = inputs.sigmoid() + inputs = inputs.flatten(1) + numerator = 2 * torch.einsum("nc,mc->nm", inputs, targets) + denominator = inputs.sum(-1)[:, None] + targets.sum(-1)[None, :] + loss = 1 - (numerator + 1) / (denominator + 1) + return loss + + +batch_dice_loss_jit = torch.jit.script(batch_dice_loss) # type: torch.jit.ScriptModule + + +def batch_sigmoid_ce_loss(inputs: torch.Tensor, targets: torch.Tensor): + """ + Args: + inputs: A float tensor of arbitrary shape. + The predictions for each example. + targets: A float tensor with the same shape as inputs. Stores the binary + classification label for each element in inputs + (0 for the negative class and 1 for the positive class). + Returns: + Loss tensor + """ + hw = inputs.shape[1] + + pos = F.binary_cross_entropy_with_logits(inputs, torch.ones_like(inputs), reduction="none") + neg = F.binary_cross_entropy_with_logits(inputs, torch.zeros_like(inputs), reduction="none") + + loss = torch.einsum("nc,mc->nm", pos, targets) + torch.einsum("nc,mc->nm", neg, (1 - targets)) + + return loss / hw + + +batch_sigmoid_ce_loss_jit = torch.jit.script(batch_sigmoid_ce_loss) # type: torch.jit.ScriptModule + + +def batch_soft_dice_loss(inputs: torch.Tensor, targets: torch.Tensor): + """ + Compute the DICE loss, similar to generalized IOU for masks + Args: + inputs: A float tensor of arbitrary shape. + The predictions for each example. + targets: A float tensor with the same shape as inputs. Stores the binary + classification label for each element in inputs + (0 for the negative class and 1 for the positive class). + """ + inputs = inputs.flatten(1) + numerator = 2 * torch.einsum("nc,mc->nm", inputs, targets) + denominator = inputs.sum(-1)[:, None] + targets.sum(-1)[None, :] + loss = (numerator + 1) / (denominator + 1) + return loss + + +batch_soft_dice_loss_jit = torch.jit.script(batch_soft_dice_loss) # type: torch.jit.ScriptModule + + +@torch.no_grad() +def accuracy(output, target, topk=(1,)): + """Computes the precision@k for the specified values of k""" + if target.numel() == 0: + return [torch.zeros([], device=output.device)] + maxk = max(topk) + batch_size = target.size(0) + + _, pred = output.topk(maxk, 1, True, True) + pred = pred.t() + correct = pred.eq(target.view(1, -1).expand_as(pred)) + + res = [] + for k in topk: + correct_k = correct[:k].view(-1).float().sum(0) + res.append(correct_k.mul_(1.0 / batch_size)) + return res + + +class SetCriterion(nn.Module): + """This class computes the loss for DETR. + The process happens in two steps: + 1) we compute hungarian assignment between ground truth boxes and the outputs of the model + 2) we supervise each pair of matched ground-truth / prediction (supervise class and box) + """ + + def __init__( + self, + num_classes: int, + matcher: nn.Module, + weight_dict: dict, + losses: list[str], + eos_coef: float = 0.1, + num_points: int = 0, + oversample_ratio: float = 3.0, + importance_sample_ratio: float = 0.0, + deep_supervision: bool = True, + use_focal: bool = False, + loss_class_type: str = "ce_loss", # ce_loss, focal_loss, bce_loss, bce_focal_loss + focal_alpha: float = 0.75, + focal_gamma: float = 2.0, + cls_sigmoid: bool = False, + ): + """Create the criterion. + Parameters: + num_classes: number of object categories, omitting the special no-object category + matcher: module able to compute a matching between targets and proposals + weight_dict: dict containing as key the names of the losses and as values their relative weight. + eos_coef: relative classification weight applied to the no-object category + losses: list of all the losses to be applied. See get_loss for list of available losses. + """ + super().__init__() + assert loss_class_type in [ + "ce_loss", + "focal_loss", + "bce_loss", + "bce_focal_loss", + ], "loss_class_type must be in ['ce_loss', 'focal_loss', 'bce_loss', 'bce_focal_loss']" + + self.num_classes = num_classes + self.matcher = matcher + self.weight_dict = weight_dict + self.losses = losses + self.deep_supervision = deep_supervision + + self.eos_coef = eos_coef + empty_weight = torch.ones(self.num_classes + 1) + empty_weight[-1] = self.eos_coef + self.register_buffer("empty_weight", empty_weight) + + # pointwise mask loss parameters + self.num_points = num_points + self.oversample_ratio = oversample_ratio + self.importance_sample_ratio = importance_sample_ratio + self.use_focal = use_focal + if use_focal: + warnings.warn( + "use_focal is deprecated. Use loss_class_type instead", + DeprecationWarning, + ) + loss_class_type = "focal_loss" if loss_class_type == "ce_loss" else loss_class_type + loss_class_type = "bce_focal_loss" if loss_class_type == "bce_loss" else loss_class_type + self.loss_class_type = loss_class_type + self.focal_alpha = focal_alpha + self.focal_gamma = focal_gamma + self.cls_sigmoid = cls_sigmoid + + def loss_labels(self, outputs, targets: list[MaskFormerTargets], indices, num_masks, log=False): + """Classification loss (NLL) + targets dicts must contain the key "labels" containing a tensor of dim [nb_target_boxes] + """ + assert "pred_logits" in outputs + src_logits = outputs["pred_logits"].float() + + idx = self._get_src_permutation_idx(indices) + target_classes_o = torch.cat([t.labels[J] for t, (_, J) in zip(targets, indices)]) + target_classes = torch.full( + src_logits.shape[:2], + self.num_classes, + dtype=torch.int64, + device=src_logits.device, + ) + target_classes[idx] = target_classes_o + + if self.loss_class_type == "focal_loss": + loss_ce = focal_loss( + src_logits.transpose(1, 2), + target_classes, + alpha=self.focal_alpha, + gamma=self.focal_gamma, + ) + elif self.loss_class_type == "ce_loss": + loss_ce = F.cross_entropy(src_logits.transpose(1, 2), target_classes, self.empty_weight) + elif self.loss_class_type == "bce_loss": + target = F.one_hot(target_classes, num_classes=self.num_classes + 1)[..., :-1] + if self.cls_sigmoid and src_logits.shape[-1] > target.shape[-1]: + src_logits = src_logits[..., :-1] + loss = F.binary_cross_entropy_with_logits(src_logits, target * 1.0, reduction="none") + loss_ce = loss.mean(1).sum() * src_logits.shape[1] / num_masks + + elif self.loss_class_type == "bce_focal_loss": + # src_logits: (b, num_queries, num_classes) = (2, 300, 80) + # target_classes_one_hot = (2, 300, 80) + target = F.one_hot(target_classes, num_classes=self.num_classes + 1)[..., :-1] + loss = torchvision.ops.sigmoid_focal_loss( + src_logits, + target.float(), + self.focal_alpha, + self.focal_gamma, + reduction="none", + ) + loss_ce = loss.mean(1).sum() * src_logits.shape[1] / num_masks + else: + raise ValueError("loss_class_type must be in ['ce_loss', 'focal_loss', 'bce_loss', 'bce_focal_loss']") + + losses = {"loss_ce": loss_ce} + return losses + + def loss_masks(self, outputs, targets: list[MaskFormerTargets], indices, num_masks): + """Compute the losses related to the masks: the focal loss and the dice loss. + targets dicts must contain the key "masks" containing a tensor of dim [nb_target_boxes, h, w] + """ + assert "pred_masks" in outputs + + src_idx = self._get_src_permutation_idx(indices) + tgt_idx = self._get_tgt_permutation_idx(indices) + src_masks = outputs["pred_masks"] + src_masks = src_masks[src_idx] + masks = [t.masks for t in targets] + # TODO use valid to mask invalid areas due to padding in loss + target_masks, valid = nested_tensor_from_tensor_list(masks).decompose() + target_masks = target_masks.to(src_masks) + target_masks = target_masks[tgt_idx] + + # No need to upsample predictions as we are using normalized coordinates :) + # N x 1 x H x W + src_masks = src_masks[:, None] + target_masks = target_masks[:, None] + + if self.num_points != 0: + with torch.no_grad(): + # sample point_coords + point_coords = get_uncertain_point_coords_with_randomness( + src_masks, + lambda logits: calculate_uncertainty(logits), + self.num_points, + self.oversample_ratio, + self.importance_sample_ratio, + ) + # get gt labels + point_labels = point_sample( + target_masks, + point_coords, + align_corners=False, + ).squeeze(1) + + point_logits = point_sample( + src_masks, + point_coords, + align_corners=False, + ).squeeze(1) + else: + src_masks = F.interpolate( + src_masks[:, None], + size=target_masks.shape[-2:], + mode="bilinear", + align_corners=False, + ) + point_logits = src_masks[:, 0].flatten(1) + + target_masks = target_masks.flatten(1) + point_labels = target_masks.view(src_masks.shape) + + losses = { + "loss_mask": sigmoid_ce_loss_jit(point_logits, point_labels, num_masks), + "loss_dice": dice_loss_jit(point_logits, point_labels, num_masks), + } + + del src_masks + del target_masks + return losses + + def _get_src_permutation_idx(self, indices): + # permute predictions following indices + batch_idx = torch.cat([torch.full_like(src, i) for i, (src, _) in enumerate(indices)]) + src_idx = torch.cat([src for (src, _) in indices]) + return batch_idx, src_idx + + def _get_tgt_permutation_idx(self, indices): + # permute targets following indices + batch_idx = torch.cat([torch.full_like(tgt, i) for i, (_, tgt) in enumerate(indices)]) + tgt_idx = torch.cat([tgt for (_, tgt) in indices]) + return batch_idx, tgt_idx + + def get_loss(self, loss, outputs, targets, indices, num_masks, **kwargs): + loss_map = { + "labels": self.loss_labels, + "masks": self.loss_masks, + } + assert loss in loss_map, f"do you really want to compute {loss} loss?" + return loss_map[loss](outputs, targets, indices, num_masks, **kwargs) + + def forward(self, outputs, targets: list[MaskFormerTargets]): + """This performs the loss computation. + Parameters: + outputs: dict of tensors, see the output specification of the model for the format + targets: list of dicts, such that len(targets) == batch_size. + The expected keys in each dict depends on the losses applied, see each loss' doc + """ + outputs_without_aux = {k: v for k, v in outputs.items() if k != "aux_outputs"} + + # Retrieve the matching between the outputs of the last layer and the targets + indices = self.matcher(outputs_without_aux, targets) + + # Compute the average number of target boxes accross all nodes, for normalization purposes + num_masks = sum(len(t.labels) for t in targets) + num_masks = torch.as_tensor([num_masks], dtype=torch.float, device=next(iter(outputs.values())).device) + if is_dist_available_and_initialized(): + torch.distributed.all_reduce(num_masks) + num_masks = torch.clamp(num_masks / get_world_size(), min=1).item() + + # Compute all the requested losses + losses = {} + for loss in self.losses: + l_dict = self.get_loss(loss, outputs, targets, indices, num_masks) + for k in list(l_dict.keys()): + if k in self.weight_dict: + l_dict[k] *= self.weight_dict[k] + losses.update(l_dict) + + # In case of auxiliary losses, we repeat this process with the output of each intermediate layer. + if "aux_outputs" in outputs and self.deep_supervision: + for i, aux_outputs in enumerate(outputs["aux_outputs"]): + indices = self.matcher(aux_outputs, targets) + for loss in self.losses: + l_dict = self.get_loss(loss, aux_outputs, targets, indices, num_masks) + for k in list(l_dict.keys()): + if k in self.weight_dict: + l_dict[k] *= self.weight_dict[k] + + l_dict = {k + f"_{i}": v for k, v in l_dict.items()} + + losses.update(l_dict) + + # In case of cdn auxiliary losses. For rtdetr + if "dn_aux_outputs" in outputs: + assert "dn_meta" in outputs, "" + indices = self.get_cdn_matched_indices(outputs["dn_meta"], targets) + num_masks = num_masks * outputs["dn_meta"]["dn_num_group"] + + for i, aux_outputs in enumerate(outputs["dn_aux_outputs"]): + # indices = self.matcher(aux_outputs, targets) + for loss in self.losses: + if loss == "masks": + # Intermediate masks losses are too costly to compute, we ignore them. + continue + l_dict = self.get_loss(loss, aux_outputs, targets, indices, num_masks) + l_dict = {k: l_dict[k] * self.weight_dict[k] for k in l_dict if k in self.weight_dict} + l_dict = {k + f"_dn_{i}": v for k, v in l_dict.items()} + losses.update(l_dict) + + return losses + + def __repr__(self): + head = "Criterion " + self.__class__.__name__ + body = [ + "matcher: {}".format(self.matcher), + "losses: {}".format(self.losses), + "weight_dict: {}".format(self.weight_dict), + "num_classes: {}".format(self.num_classes), + "eos_coef: {}".format(self.eos_coef), + "num_points: {}".format(self.num_points), + "oversample_ratio: {}".format(self.oversample_ratio), + "importance_sample_ratio: {}".format(self.importance_sample_ratio), + ] + _repr_indent = 4 + lines = [head] + [" " * _repr_indent + line for line in body] + return "\n".join(lines) + + +class MaskHungarianMatcher(nn.Module): + """This class computes an assignment between the targets and the predictions of the network + + For efficiency reasons, the targets don't include the no_object. Because of this, in general, + there are more predictions than targets. In this case, we do a 1-to-1 matching of the best predictions, + while the others are un-matched (and thus treated as non-objects). + """ + + def __init__( + self, + cost_class: float = 1, + cost_mask: float = 1, + cost_dice: float = 1, + num_points: int = 0, + cls_sigmoid: bool = False, + ): + """Creates the matcher + + Params: + cost_class: This is the relative weight of the classification error in the matching cost + cost_mask: This is the relative weight of the focal loss of the binary mask in the matching cost + cost_dice: This is the relative weight of the dice loss of the binary mask in the matching cost + num_points: Number of points to sample from the mask for matching + cls_sigmoid: Whether to apply sigmoid to the classification logits + """ + super().__init__() + self.cost_class = cost_class + self.cost_mask = cost_mask + self.cost_dice = cost_dice + self.cls_sigmoid = cls_sigmoid + + assert cost_class != 0 or cost_mask != 0 or cost_dice != 0, "all costs cant be 0" + + self.num_points = num_points + + @torch.no_grad() + def memory_efficient_forward(self, outputs, targets: list[MaskFormerTargets]): + """More memory-friendly matching""" + bs, num_queries = outputs["pred_logits"].shape[:2] + + indices = [] + + # Iterate through batch size + for b in range(bs): + if self.cls_sigmoid: + out_prob = outputs["pred_logits"][b].sigmoid() + else: + out_prob = outputs["pred_logits"][b].softmax(-1) # [num_queries, num_classes] + tgt_ids = targets[b].labels + + # Compute the classification cost. Contrary to the loss, we don't use the NLL, + # but approximate it in 1 - proba[target class]. + # The 1 is a constant that doesn't change the matching, it can be ommitted. + cost_class = -out_prob[:, tgt_ids] + + out_mask = outputs["pred_masks"][b] # [num_queries, H_pred, W_pred] + # gt masks are already padded when preparing target + tgt_mask = targets[b].masks.to(out_mask) + + out_mask = out_mask[:, None] + tgt_mask = tgt_mask[:, None] + # all masks share the same set of points for efficient matching! + point_coords = torch.rand(1, self.num_points, 2, device=out_mask.device) + # get gt labels + tgt_mask = point_sample( + tgt_mask, + point_coords.repeat(tgt_mask.shape[0], 1, 1), + align_corners=False, + ).squeeze(1) + + out_mask = point_sample( + out_mask, + point_coords.repeat(out_mask.shape[0], 1, 1), + align_corners=False, + ).squeeze(1) + + with autocast(device_type="cuda", enabled=False): + out_mask = out_mask.float() + tgt_mask = tgt_mask.float() + # Compute the focal loss between masks + cost_mask = batch_sigmoid_ce_loss_jit(out_mask, tgt_mask) + + # Compute the dice loss betwen masks + cost_dice = batch_dice_loss_jit(out_mask, tgt_mask) + + # Final cost matrix + C = self.cost_mask * cost_mask + self.cost_class * cost_class + self.cost_dice * cost_dice + C = C.reshape(num_queries, -1).cpu() + + indices.append(linear_sum_assignment(C)) + + return [ + ( + torch.as_tensor(i, dtype=torch.int64), + torch.as_tensor(j, dtype=torch.int64), + ) + for i, j in indices + ] + + @torch.no_grad() + def forward(self, outputs, targets): + """Performs the matching + + Params: + outputs: This is a dict that contains at least these entries: + "pred_logits": Tensor of dim [batch_size, num_queries, num_classes] with the classification logits + "pred_masks": Tensor of dim [batch_size, num_queries, H_pred, W_pred] with the predicted masks + + targets: This is a list of targets (len(targets) = batch_size), where each target is a dict containing: + "labels": Tensor of dim [num_target_boxes] (where num_target_boxes is the number of ground-truth + objects in the target) containing the class labels + "masks": Tensor of dim [num_target_boxes, H_gt, W_gt] containing the target masks + + Returns: + A list of size batch_size, containing tuples of (index_i, index_j) where: + - index_i is the indices of the selected predictions (in order) + - index_j is the indices of the corresponding selected targets (in order) + For each batch element, it holds: + len(index_i) = len(index_j) = min(num_queries, num_target_boxes) + """ + return self.memory_efficient_forward(outputs, targets) + + def __repr__(self, _repr_indent=4): + head = "Matcher " + self.__class__.__name__ + body = [ + "cost_class: {}".format(self.cost_class), + "cost_mask: {}".format(self.cost_mask), + "cost_dice: {}".format(self.cost_dice), + ] + lines = [head] + [" " * _repr_indent + line for line in body] + return "\n".join(lines) diff --git a/focoos/models/fai_mf/modelling.py b/focoos/models/fai_mf/modelling.py new file mode 100644 index 00000000..0974c503 --- /dev/null +++ b/focoos/models/fai_mf/modelling.py @@ -0,0 +1,725 @@ +from typing import Dict, Optional + +import torch +import torch.nn as nn +import torch.nn.functional as F + +from focoos.models.fai_mf.config import MaskFormerConfig +from focoos.models.fai_mf.loss import MaskHungarianMatcher, SetCriterion +from focoos.models.fai_mf.ports import MaskFormerModelOutput, MaskFormerTargets +from focoos.models.focoos_model import BaseModelNN +from focoos.nn.backbone.base import BaseBackbone +from focoos.nn.backbone.build import load_backbone +from focoos.nn.layers.base import MLP +from focoos.nn.layers.conv import Conv2d +from focoos.nn.layers.position_encoding import PositionEmbeddingSine +from focoos.nn.layers.transformer import ( + CrossAttentionLayer, + FFNLayer, + SelfAttentionLayer, + TransformerEncoder, + TransformerEncoderLayer, +) +from focoos.utils.logger import get_logger + +logger = get_logger(__name__) + + +class PredictionHeads(nn.Module): + """Prediction heads for mask classification and segmentation.""" + + def __init__( + self, + hidden_dim, + num_classes, + mask_dim, + num_heads, + mask_classification, + use_attn_masks, + ): + """Initialize prediction heads. + + Args: + hidden_dim: Dimension of hidden features + num_classes: Number of classes to predict + mask_dim: Dimension of mask features + num_heads: Number of attention heads + mask_classification: Whether to perform mask classification + use_attn_masks: Whether to use attention masks + """ + super().__init__() + self.decoder_norm = nn.LayerNorm(hidden_dim) + # output FFNs + self.classifier = nn.Linear(hidden_dim, num_classes + 1) + self.mask_classifier = MLP(hidden_dim, hidden_dim, mask_dim, 3) + + self.num_heads = num_heads + self.use_attn_masks = use_attn_masks + self.num_classes = num_classes + + def reset_classifier(self, num_classes: Optional[int] = None): + """Reset the classifier with a new number of classes. + + Args: + num_classes: New number of classes (optional) + """ + _num_classes = num_classes if num_classes else self.num_classes + self.classifier = nn.Linear(self.classifier.in_features, _num_classes + 1).to(self.classifier.weight.device) + + def forward(self, x, mask_features, sizes=None, process=True): + """Forward pass for prediction heads. + + Args: + x: Input features + mask_features: Mask features + sizes: Target sizes for attention masks + process: Whether to process attention masks + + Returns: + Class logits, mask predictions, and optionally attention masks + """ + decoder_output = self.decoder_norm(x) + decoder_output = decoder_output.transpose(0, 1) + # just a linear layer [hidden, n_class + 1] + outputs_class = self.classifier(decoder_output) + mask_embed = self.mask_classifier(decoder_output) # MLP with 3 linear layer + outputs_mask = torch.einsum("bqc,bchw->bqhw", mask_embed, mask_features) + + if sizes is not None: + if self.use_attn_masks: + attn_masks = [] + if not isinstance(sizes, list): + sizes = [sizes] + for attn_mask_target_size in sizes: + # NOTE: prediction is of higher-resolution + # [B, Q, H, W] -> [B, Q, H*W] -> [B, h, Q, H*W] -> [B*h, Q, HW] + attn_mask = F.interpolate( + outputs_mask, + size=attn_mask_target_size, + mode="bilinear", + align_corners=False, + ) + if process: + # must use bool type + # If a BoolTensor is provided, positions with ``True`` are not allowed to attend while ``False`` values will be unchanged. + attn_mask = attn_mask.flatten(2) < 0 + attn_mask = attn_mask.detach() if self.training else attn_mask + attn_masks.append(attn_mask) + else: + attn_masks = None + return outputs_class, outputs_mask, attn_masks + else: + return outputs_class, outputs_mask + + def forward_class_only(self, x): + """Forward pass for class prediction only. + + Args: + x: Input features + + Returns: + Class logits + """ + decoder_output = self.decoder_norm(x) + decoder_output = decoder_output.transpose(0, 1) + # just a linear layer [hidden, n_class + 1] + outputs_class = self.classifier(decoder_output) + return outputs_class + + +class TransformerEncoderOnly(nn.Module): + """Transformer encoder-only architecture.""" + + def __init__( + self, + d_model=512, + nhead=8, + num_encoder_layers=6, + dim_feedforward=2048, + dropout=0.1, + activation="relu", + normalize_before=False, + ): + """Initialize transformer encoder-only architecture. + + Args: + d_model: Dimension of the model + nhead: Number of attention heads + num_encoder_layers: Number of encoder layers + dim_feedforward: Dimension of the feedforward network + dropout: Dropout probability + activation: Activation function + normalize_before: Whether to apply normalization before layers + """ + super().__init__() + + encoder_layer = TransformerEncoderLayer( + d_model, + nhead, + dim_feedforward, + dropout, + activation, + normalize_before, + batch_first=False, + ) + encoder_norm = nn.LayerNorm(d_model) if normalize_before else None + self.encoder = TransformerEncoder(encoder_layer, num_encoder_layers, encoder_norm) + + self._reset_parameters() + + self.d_model = d_model + self.nhead = nhead + + def _reset_parameters(self): + """Initialize parameters with Xavier uniform distribution.""" + for p in self.parameters(): + if p.dim() > 1: + nn.init.xavier_uniform_(p) + + def forward(self, src, mask, pos_embed): + """Forward pass for transformer encoder. + + Args: + src: Source tensor + mask: Attention mask + pos_embed: Positional embedding + + Returns: + Output tensor after transformer encoding + """ + # flatten NxCxHxW to HWxNxC + bs, c, h, w = src.shape + src = src.flatten(2).permute(2, 0, 1) + pos_embed = pos_embed.flatten(2).permute(2, 0, 1) + if mask is not None: + mask = mask.flatten(1) + + memory = self.encoder(src, src_key_padding_mask=mask, pos_embed=pos_embed) + return memory.permute(1, 2, 0).view(bs, c, h, w) + + +class TransformerFPN(nn.Module): + """Feature Pyramid Network with optional transformer layers.""" + + def __init__( + self, + backbone: BaseBackbone, + feat_dim: int, + out_dim: int, + transformer_layers: int = 0, + transformer_dropout: float = 0.0, + transformer_nheads: int = 8, + transformer_dim_feedforward: int = 1024, + transformer_pre_norm: bool = True, + ): + """Initialize Transformer Feature Pyramid Network. + + Args: + backbone: Backbone network to extract features + feat_dim: Number of channels for intermediate conv layers + out_dim: Number of channels for final conv layer + transformer_layers: Number of transformer encoder layers + transformer_dropout: Dropout probability for transformer + backbone: basic backbones to extract features from images + feat_dim: number of output channels for the intermediate conv layers. + out_dim: number of output channels for the final conv layer. + norm (str or callable): normalization for all conv layers + """ + super().__init__() + + self.backbone = backbone + self.input_shape = sorted(backbone.output_shape().items(), key=lambda x: x[1].stride) # type: ignore + # starting from "res2" to "res5" + self.in_features = [k for k, v in self.input_shape] + # starting from "res2" to "res5" + self.in_channels = [v.channels for k, v in self.input_shape] + self.in_strides = [v.stride for k, v in self.input_shape] + self.out_dim = out_dim + self.feat_dim = feat_dim + feature_channels = [v.channels for k, v in self.input_shape] + + if transformer_layers > 0: + in_channels = feature_channels[len(self.in_features) - 1] + self.input_proj = Conv2d(in_channels, feat_dim, kernel_size=1) + # weight_init.c2_xavier_fill(self.input_proj) + nn.init.kaiming_uniform_(self.input_proj.weight, a=1) + if self.input_proj.bias is not None: + nn.init.constant_(self.input_proj.bias, 0) + self.transformer = TransformerEncoderOnly( + d_model=feat_dim, + dropout=transformer_dropout, + nhead=transformer_nheads, + dim_feedforward=transformer_dim_feedforward, + num_encoder_layers=transformer_layers, + normalize_before=transformer_pre_norm, + ) + N_steps = feat_dim // 2 + self.pe_layer = PositionEmbeddingSine(N_steps, normalize=True) + else: + self.input_proj = None + self.transformer = None + self.pe_layer = None + + lateral_convs = [] + output_convs = [] + + use_bias = False + for idx, in_channels in enumerate(feature_channels): + if idx == len(self.in_features) - 1: + output_norm = nn.BatchNorm2d(feat_dim) + output_conv = Conv2d( + feat_dim if transformer_layers > 0 else in_channels, + feat_dim, + kernel_size=3, + stride=1, + padding=1, + bias=use_bias, + norm=output_norm, + activation=F.relu, + ) + # weight_init.c2_xavier_fill(output_conv) + nn.init.kaiming_uniform_(output_conv.weight, a=1) + if output_conv.bias is not None: + nn.init.constant_(output_conv.bias, 0) + self.add_module("layer_{}".format(idx + 1), output_conv) + + lateral_convs.append(None) + output_convs.append(output_conv) + else: + lateral_norm = nn.BatchNorm2d(feat_dim) + output_norm = nn.BatchNorm2d(feat_dim) + + lateral_conv = Conv2d( + in_channels, + feat_dim, + kernel_size=1, + bias=use_bias, + norm=lateral_norm, + ) + output_conv = Conv2d( + feat_dim, + feat_dim, + kernel_size=3, + stride=1, + padding=1, + bias=use_bias, + norm=output_norm, + activation=F.relu, + ) + # weight_init.c2_xavier_fill(lateral_conv) + nn.init.kaiming_uniform_(lateral_conv.weight, a=1) + if lateral_conv.bias is not None: + nn.init.constant_(lateral_conv.bias, 0) + # weight_init.c2_xavier_fill(output_conv) + nn.init.kaiming_uniform_(output_conv.weight, a=1) + if output_conv.bias is not None: + nn.init.constant_(output_conv.bias, 0) + self.add_module("adapter_{}".format(idx + 1), lateral_conv) + self.add_module("layer_{}".format(idx + 1), output_conv) + + lateral_convs.append(lateral_conv) + output_convs.append(output_conv) + # Place convs into top-down order (from low to high resolution) + # to make the top-down computation in forward clearer. + self.lateral_convs = lateral_convs[::-1] + self.output_convs = output_convs[::-1] + + self.mask_dim = out_dim + self.mask_features = Conv2d( + feat_dim, + out_dim, + kernel_size=3, + stride=1, + padding=1, + ) + # weight_init.c2_xavier_fill(self.mask_features) + nn.init.kaiming_uniform_(self.mask_features.weight, a=1) + if self.mask_features.bias is not None: + nn.init.constant_(self.mask_features.bias, 0) + + @property + def padding_constraints(self) -> Dict[str, int]: + return self.backbone.padding_constraints + + def forward(self, images: torch.Tensor): + features = self.backbone(images) + return self.forward_features(features) + + def forward_features(self, features): + multi_scale_features = [] + num_cur_levels = 0 + # Reverse feature maps into top-down order (from low to high resolution) + for idx, f in enumerate(self.in_features[::-1]): + x = features[f] + lateral_conv = self.lateral_convs[idx] + output_conv = self.output_convs[idx] + if lateral_conv is None: + if self.transformer is not None and self.input_proj is not None and self.pe_layer is not None: + x = self.input_proj(x) + x = self.transformer(x, mask=None, pos_embed=self.pe_layer(x)) + y = output_conv(x) + else: + cur_fpn = lateral_conv(x) + # Following FPN implementation, we use nearest upsampling here + y = cur_fpn + F.interpolate(y, size=cur_fpn.shape[-2:], mode="nearest") + y = output_conv(y) + if num_cur_levels < 3: + multi_scale_features.append(y) + num_cur_levels += 1 + return self.mask_features(y), multi_scale_features + + +class MultiScaleMaskedTransformerDecoder(nn.Module): + def __init__( + self, + in_channels, + out_dim, + *, + num_classes: int, + hidden_dim: int, + num_queries: int, + nheads: int, + dim_feedforward: int, + dec_layers: int, + num_scales: int = 3, + pre_norm: bool, + enforce_input_project: bool, + use_attn_masks: bool = True, + ): + super().__init__() + + self.use_attn_masks = use_attn_masks + + # positional encoding + N_steps = hidden_dim // 2 + self.pe_layer = PositionEmbeddingSine(N_steps, normalize=True) + + assert 0 < num_scales <= 3, "num_scales must between 1 and 3" + # define Transformer decoder here + self.num_heads = nheads + self.num_layers = dec_layers + self.num_scales = num_scales + self.num_classes = num_classes + self.transformer_self_attention_layers = nn.ModuleList() + self.transformer_cross_attention_layers = nn.ModuleList() + self.transformer_ffn_layers = nn.ModuleList() + + for _ in range(self.num_layers): + self.transformer_self_attention_layers.append( + SelfAttentionLayer( + d_model=hidden_dim, + nhead=nheads, + dropout=0.0, + normalize_before=pre_norm, + ) + ) + + self.transformer_cross_attention_layers.append( + CrossAttentionLayer( + d_model=hidden_dim, + nhead=nheads, + dropout=0.0, + normalize_before=pre_norm, + ) + ) + + self.transformer_ffn_layers.append( + FFNLayer( + d_model=hidden_dim, + dim_feedforward=dim_feedforward, + dropout=0.0, + normalize_before=pre_norm, + ) + ) + + self.num_queries = num_queries + # learnable query features + self.query_feat = nn.Embedding(num_queries, hidden_dim) + # learnable query p.e. + self.query_embed = nn.Embedding(num_queries, hidden_dim) + + # level embedding (we always use 3 scales) + self.num_feature_levels = min(self.num_scales, dec_layers) + # self.level_embed = nn.Embedding(self.num_feature_levels, hidden_dim) + self.input_proj = nn.ModuleList() + for _ in range(min(self.num_feature_levels, dec_layers)): + if in_channels != hidden_dim or enforce_input_project: + self.input_proj.append(Conv2d(in_channels, hidden_dim, kernel_size=1)) + # weight_init.c2_xavier_fill(self.input_proj[-1]) + nn.init.kaiming_uniform_(self.input_proj[-1].weight, a=1) + if self.input_proj[-1].bias is not None: + nn.init.constant_(self.input_proj[-1].bias, 0) + else: + self.input_proj.append(nn.Sequential()) + + self.forward_prediction_heads = PredictionHeads( + hidden_dim, + num_classes, + out_dim, + nheads, + mask_classification=True, + use_attn_masks=use_attn_masks, + ) + + def reset_classifier(self, num_classes: Optional[int] = None): + self.forward_prediction_heads.reset_classifier(num_classes if num_classes else self.num_classes) + + def forward(self, x, mask_features, targets=None, mask=None): + # x is a list of multi-scale feature + # assert len(x) == self.num_feature_levels + src = [] + pos = [] + size_list = [] + + x = x[: self.num_scales] + + # disable mask, it does not affect performance + del mask + + for i in range(self.num_feature_levels): + size_list.append(x[i].shape[-2:]) + pos.append(self.pe_layer(x[i], None).flatten(2)) + # + self.level_embed(i)[None, :, None]) + src.append(self.input_proj[i](x[i]).flatten(2)) + + # flatten NxCxHxW to HWxNxC + pos[-1] = pos[-1].permute(2, 0, 1) + src[-1] = src[-1].permute(2, 0, 1) + + _, bs, _ = src[0].shape + + # QxNxC + query_embed = self.query_embed.weight.unsqueeze(1).repeat(1, bs, 1) + output = self.query_feat.weight.unsqueeze(1).repeat(1, bs, 1) + + predictions_class = [] + predictions_mask = [] + + # prediction heads on learnable query features + outputs_class, outputs_mask, attn_mask = self.forward_prediction_heads( + output, mask_features, sizes=size_list[0] + ) + attn_mask = attn_mask[0] if self.use_attn_masks else None + predictions_class.append(outputs_class) + predictions_mask.append(outputs_mask) + + for i in range(self.num_layers): + level_index = i % self.num_feature_levels + # if on a mask is all True (no pixel active), use cross attention (put every pixel at False) + # B N # 1 if any False, 0 if all True + if attn_mask is not None: + m_mask = (attn_mask.sum(-1) != attn_mask.shape[-1]).unsqueeze(-1) + attn_mask = attn_mask.type_as(output) * m_mask.type_as(output) + attn_mask = attn_mask.bool().unsqueeze(1).repeat(1, self.num_heads, 1, 1).flatten(0, 1) + # attention: cross-attention first + output = self.transformer_cross_attention_layers[i]( + output, # query + src[level_index], # key and value + memory_mask=attn_mask if self.use_attn_masks else None, + memory_key_padding_mask=None, # here we do not apply masking on padded region + pos=pos[level_index], + query_pos=query_embed, + ) + + output = self.transformer_self_attention_layers[i]( + output, tgt_mask=None, tgt_key_padding_mask=None, query_pos=query_embed + ) + + # FFN + output = self.transformer_ffn_layers[i](output) + + outputs_class, outputs_mask, attn_mask = self.forward_prediction_heads( + output, + mask_features, + sizes=size_list[(i + 1) % self.num_feature_levels], + ) + attn_mask = attn_mask[0] if self.use_attn_masks else None + predictions_class.append(outputs_class) + predictions_mask.append(outputs_mask) + + assert len(predictions_class) == self.num_layers + 1 + + out = { + "pred_logits": predictions_class[-1], + "pred_masks": predictions_mask[-1], + "aux_outputs": self._set_aux_loss( + predictions_class, + predictions_mask, + ), + } + return out + + @torch.jit.unused + def _set_aux_loss(self, outputs_class, outputs_seg_masks): + # this is a workaround to make torchscript happy, as torchscript + # doesn't support dictionary with non-homogeneous values, such + # as a dict having both a Tensor and a list. + return [{"pred_logits": a, "pred_masks": b} for a, b in zip(outputs_class[:-1], outputs_seg_masks[:-1])] + + +class MaskFormerHead(nn.Module): + def __init__( + self, + *, + in_channels: int, + out_dim: int, + num_classes: int, + criterion: nn.Module, + ignore_value: int = -1, + # extra parameters + transformer_predictor: nn.Module, + cls_sigmoid=False, + ): + """ + Args: + num_classes: number of classes to predict + loss_weight: loss weight + ignore_value: category id to be ignored during training. + transformer_predictor: the transformer decoder that makes prediction + """ + super().__init__() + + self.in_channels = in_channels + self.out_dim = out_dim + self.ignore_value = ignore_value + self.criterion = criterion + + self.predictor = transformer_predictor + + self.num_classes = num_classes + self.metadata = None + self.cls_sigmoid = cls_sigmoid + self.mask_threshold = 0 + + def reset_classifier(self, num_classes: Optional[int] = None): + self.predictor.reset_classifier(num_classes if num_classes else self.num_classes) + + def layers(self, features, targets=None, mask=None): + mask_features, multi_scale_features = features + predictions = self.predictor(multi_scale_features, mask_features, targets=targets, mask=mask) + + return predictions + + def forward(self, features, targets: list[MaskFormerTargets] = []): + outputs = self.layers(features, targets=targets) + + loss = None + if targets is not None and len(targets) > 0: + loss = self.losses(outputs, targets) + + if isinstance(outputs, tuple): + outputs = outputs[0] + mask_cls = outputs["pred_logits"] + mask_pred = outputs["pred_masks"] + + if self.cls_sigmoid: + mask_cls = mask_cls.sigmoid()[..., :-1] + else: + mask_cls = F.softmax(mask_cls, dim=-1)[..., :-1] + mask_pred = mask_pred.sigmoid() + + return (mask_cls, mask_pred), loss + + def losses(self, predictions, targets): + if isinstance(predictions, tuple): + predictions, mask_dict = predictions + losses = self.criterion(predictions, targets, mask_dict) + else: + losses = self.criterion(predictions, targets) + + return losses + + +class FAIMaskFormer(BaseModelNN): + def __init__(self, config: MaskFormerConfig): + super().__init__(config) + self._export = False + self.config = config + accepted_postprocessing_types = ["semantic", "instance"] + if self.config.postprocessing_type not in accepted_postprocessing_types: + raise ValueError( + f"Invalid postprocessing type: {self.config.postprocessing_type}. Must be one of: {accepted_postprocessing_types}" + ) + + backbone = load_backbone(self.config.backbone_config) + + self.pixel_decoder = TransformerFPN( + backbone=backbone, + feat_dim=self.config.pixel_decoder_feat_dim, + out_dim=self.config.pixel_decoder_out_dim, + transformer_layers=self.config.pixel_decoder_transformer_layers, + transformer_dropout=self.config.pixel_decoder_transformer_dropout, + transformer_nheads=self.config.pixel_decoder_transformer_nheads, + transformer_dim_feedforward=self.config.pixel_decoder_transformer_dim_feedforward, + ) + self.head = MaskFormerHead( + in_channels=self.config.transformer_predictor_out_dim, + out_dim=self.config.head_out_dim, + num_classes=self.config.num_classes, + ignore_value=255, + criterion=SetCriterion( + num_classes=self.config.num_classes, + matcher=MaskHungarianMatcher( + cost_class=self.config.matcher_cost_class, + cost_mask=self.config.matcher_cost_mask, + cost_dice=self.config.matcher_cost_dice, + num_points=self.config.criterion_num_points, + cls_sigmoid=self.config.cls_sigmoid, + ), + weight_dict={ + "loss_ce": self.config.weight_dict_loss_ce, + "loss_mask": self.config.weight_dict_loss_mask, + "loss_dice": self.config.weight_dict_loss_dice, + }, + deep_supervision=self.config.criterion_deep_supervision, + eos_coef=self.config.criterion_eos_coef, + losses=["labels", "masks"], + num_points=self.config.criterion_num_points, + oversample_ratio=3.0, + importance_sample_ratio=0.75, + loss_class_type="ce_loss" if not self.config.cls_sigmoid else "bce_loss", + cls_sigmoid=self.config.cls_sigmoid, + ), + transformer_predictor=MultiScaleMaskedTransformerDecoder( + in_channels=self.config.pixel_decoder_out_dim, + out_dim=self.config.transformer_predictor_out_dim, + num_classes=self.config.num_classes, + hidden_dim=self.config.transformer_predictor_hidden_dim, # this is query dim + num_queries=self.config.num_queries, + nheads=8, + dim_feedforward=self.config.transformer_predictor_dim_feedforward, + dec_layers=self.config.transformer_predictor_dec_layers, + num_scales=3, + pre_norm=True, + enforce_input_project=True, + use_attn_masks=True, + ), + cls_sigmoid=self.config.cls_sigmoid, + ) + self.register_buffer("pixel_mean", torch.Tensor(self.config.pixel_mean).view(-1, 1, 1), False) + self.register_buffer("pixel_std", torch.Tensor(self.config.pixel_std).view(-1, 1, 1), False) + self.size_divisibility = self.config.size_divisibility + self.num_classes = self.config.num_classes + + @property + def device(self): + return self.pixel_mean.device + + @property + def dtype(self): + return self.pixel_mean.dtype + + def forward( + self, + images: torch.Tensor, + targets: list[MaskFormerTargets] = [], + ) -> MaskFormerModelOutput: + images = (images - self.pixel_mean) / self.pixel_std # type: ignore + + features = self.pixel_decoder(images) + (logits, masks), losses = self.head(features, targets) + + if not self.training: + masks = F.interpolate(masks, size=images.shape[2:], mode="bilinear", align_corners=False) + + return MaskFormerModelOutput(masks=masks, logits=logits, loss=losses) diff --git a/focoos/models/fai_mf/ports.py b/focoos/models/fai_mf/ports.py new file mode 100644 index 00000000..18f6708a --- /dev/null +++ b/focoos/models/fai_mf/ports.py @@ -0,0 +1,19 @@ +from dataclasses import dataclass +from typing import Optional + +import torch + +from focoos.ports import ModelOutput + + +@dataclass +class MaskFormerModelOutput(ModelOutput): + masks: torch.Tensor # [N, num_queries, H, W] + logits: torch.Tensor # [N, num_queries, num_classes] + loss: Optional[dict] + + +@dataclass +class MaskFormerTargets: + labels: torch.Tensor + masks: torch.Tensor diff --git a/focoos/models/fai_mf/processor.py b/focoos/models/fai_mf/processor.py new file mode 100644 index 00000000..bd696335 --- /dev/null +++ b/focoos/models/fai_mf/processor.py @@ -0,0 +1,345 @@ +from typing import Optional, Union + +import numpy as np +import torch +from PIL import Image + +from focoos.models.fai_mf.config import MaskFormerConfig +from focoos.models.fai_mf.ports import MaskFormerModelOutput, MaskFormerTargets +from focoos.ports import DatasetEntry, DynamicAxes, FocoosDet, FocoosDetections +from focoos.processor.base_processor import Processor +from focoos.structures import BitMasks, ImageList, Instances +from focoos.utils.memory import retry_if_cuda_oom +from focoos.utils.vision import binary_mask_to_base64, masks_to_xyxy, trim_mask + + +def interpolate_image(image, size): + return torch.nn.functional.interpolate( + image.unsqueeze(0), + size=size, + mode="bilinear", + align_corners=False, + )[0] + + +class MaskFormerProcessor(Processor): + def __init__( + self, + config: MaskFormerConfig, + ): + super().__init__(config) + processing_functions = { + "semantic": self.semantic_inference, + "instance": self.instance_inference, + } + self.config = config + self.eval_output_name = "sem_seg" if config.postprocessing_type == "semantic" else "instances" + assert config.postprocessing_type in processing_functions, ( + f"Invalid postprocessing type: {config.postprocessing_type}. Must be one of: {processing_functions.keys()}" + ) + self.processing_fn = processing_functions[config.postprocessing_type] + + self.num_classes = config.num_classes + self.mask_threshold = config.mask_threshold + self.top_k = config.top_k + self.threshold = config.threshold + self.use_mask_score = config.use_mask_score + self.predict_all_pixels = config.predict_all_pixels + + def preprocess( + self, + inputs: Union[ + torch.Tensor, + np.ndarray, + Image.Image, + list[Image.Image], + list[np.ndarray], + list[torch.Tensor], + list[DatasetEntry], + ], + device: torch.device, + dtype: torch.dtype = torch.float32, + image_size: Optional[int] = None, + ) -> tuple[torch.Tensor, list[MaskFormerTargets]]: + targets = [] + if isinstance(inputs, list) and len(inputs) > 0 and isinstance(inputs[0], DatasetEntry): + images = [x.image.to(device) for x in inputs] # type: ignore + images = ImageList.from_tensors( + tensors=images, + ) + images_torch = images.tensor + if self.training: + # mask classification target + gt_instances = [x.instances.to(device) for x in inputs] # type: ignore + h, w = images.tensor.shape[-2:] + targets = [] + for targets_per_image in gt_instances: + assert targets_per_image.masks is not None, "masks are required for training" + gt_masks = targets_per_image.masks.tensor + if len(gt_masks) > 0: + padded_masks = torch.zeros( + (gt_masks.shape[0], h, w), + dtype=gt_masks.dtype, + device=gt_masks.device, + ) + padded_masks[:, : gt_masks.shape[1], : gt_masks.shape[2]] = gt_masks + else: + padded_masks = gt_masks + assert targets_per_image.classes is not None, "classes are required for training" + cls_labels = targets_per_image.classes + targets.append(MaskFormerTargets(labels=cls_labels, masks=padded_masks)) + else: + if self.training: + raise ValueError("During training, inputs should be a list of DetectionDatasetDict") + images_torch = self.get_tensors(inputs).to(device, dtype=dtype) # type: ignore + # since we can process input of different sizes, we are not using image_size input + + return images_torch, targets + + def semantic_inference( + self, + mask_cls, + mask_pred, + ) -> torch.Tensor: + semseg = torch.einsum("qc,qhw->chw", mask_cls, mask_pred) + return semseg + + def instance_inference( + self, + mask_cls, + mask_pred, + ) -> Instances: + # mask_pred is already processed to have the same shape as original input + image_size = mask_pred.shape[-2:] + num_queries = mask_pred.shape[0] + + # [Q, K] + # todo: merge this with the modeling top_k in the forward pass + scores = mask_cls + labels = ( + torch.arange(self.num_classes, device=mask_cls.device).unsqueeze(0).repeat(num_queries, 1).flatten(0, 1) + ) + # scores_per_image, topk_indices = scores.flatten(0, 1).topk(self.num_queries, sorted=False) + scores_per_image, topk_indices = scores.flatten(0, 1).topk(self.top_k, sorted=False) + labels_per_image = labels[topk_indices] + + topk_indices = topk_indices // self.num_classes + + mask_pred = mask_pred[topk_indices] + + bin_masks = mask_pred > self.mask_threshold + bin_masks = bin_masks * 1e-3 + mask_scores_per_image = (bin_masks.flatten(1) * mask_pred.flatten(1)).sum(1) / ( + bin_masks.flatten(1).sum(1) + 1e-6 + ) + + masks = BitMasks(bin_masks.float()) + boxes = masks.get_bounding_boxes() + scores = scores_per_image * mask_scores_per_image + classes = labels_per_image + return Instances(image_size, boxes=boxes, masks=masks, scores=scores, classes=classes) + + def eval_postprocess( + self, + output: MaskFormerModelOutput, + batched_inputs: list[DatasetEntry], + ) -> list[dict[str, Union[Instances, torch.Tensor]]]: + results = [] + cls_pred = output.logits + mask_pred = output.masks + + for i in range(len(batched_inputs)): + # get "augmented" images size and next original size + size = batched_inputs[i].image.shape[-2:] # type: ignore + height = batched_inputs[i].height + width = batched_inputs[i].width + mask_pred_result = mask_pred[i] + mask_cls_result = cls_pred[i] + + out_stride = size[1] // mask_pred_result.shape[2] + mask_pred_result = mask_pred_result[:, : 1 + size[0] // out_stride, : 1 + size[1] // out_stride] + + mask_pred_result = retry_if_cuda_oom(interpolate_image)(mask_pred_result, (height, width)) + result = self.processing_fn(mask_cls_result, mask_pred_result) + results.append({self.eval_output_name: result}) + + return results + + def postprocess( + self, + output: MaskFormerModelOutput, + inputs: Union[ + torch.Tensor, + np.ndarray, + Image.Image, + list[Image.Image], + list[np.ndarray], + list[torch.Tensor], + ], + class_names: list[str] = [], + top_k: Optional[int] = None, + threshold: Optional[float] = None, + use_mask_score: Optional[bool] = None, + predict_all_pixels: Optional[bool] = None, + ) -> list[FocoosDetections]: + top_k = top_k or self.top_k + threshold = threshold or self.threshold + use_mask_score = use_mask_score or self.use_mask_score + predict_all_pixels = predict_all_pixels or self.predict_all_pixels + + # Extract image sizes from inputs + image_sizes = self.get_image_sizes(inputs) + + batch_size = output.logits.shape[0] + results = [] + assert len(image_sizes) == batch_size, ( + f"Expected image sizes {len(image_sizes)} to match batch size {batch_size}" + ) + + cls_pred, mask_pred = ( + output.logits, + output.masks, + ) # B x Q; B x Q x H/out_stride x W/out_stride + # softmax done before. # B x Q; B x Q + scores, labels = cls_pred.max(-1) + + # # let's binarize the mask + if predict_all_pixels: + b, q, h, w = mask_pred.shape + p = scores.view(b, q, 1, 1) * mask_pred + out = p.argmax(dim=1) # Shape: [b, h, w] + + # Initialize an empty tensor for bin_mask_pred + bin_mask_pred = torch.zeros((b, q, h, w), dtype=torch.bool, device=mask_pred.device) + + # Process each batch instance separately + for batch_idx in range(b): + # Create a mask for each class in this batch + for class_idx in range(q): + # Set True where the argmax equals this class index + bin_mask_pred[batch_idx, class_idx] = out[batch_idx] == class_idx + else: + bin_mask_pred = mask_pred >= self.mask_threshold # B x Q x H x W + + # Find masks with zero sum + non_zero_masks = bin_mask_pred.sum(dim=(-2, -1)) > 1 # B x top_k_masks + # Set scores and labels to 0 for empty masks + # Get indices of non-zero masks + non_zero_indices = (non_zero_masks).nonzero(as_tuple=True) + # Filter scores, labels and bin_mask_pred to only keep non-zero masks + scores = torch.gather(scores, dim=1, index=non_zero_indices[1].unsqueeze(0)) + labels = torch.gather(labels, dim=1, index=non_zero_indices[1].unsqueeze(0)) + + bin_mask_pred = torch.gather( + bin_mask_pred, + dim=1, + index=non_zero_indices[1] + .unsqueeze(0) + .unsqueeze(-1) + .unsqueeze(-1) + .expand(-1, -1, *bin_mask_pred.shape[-2:]), + ) + + mask_pred = torch.gather( + mask_pred, + dim=1, + index=non_zero_indices[1].unsqueeze(0).unsqueeze(-1).unsqueeze(-1).expand(-1, -1, *mask_pred.shape[-2:]), + ) + + if use_mask_score: + bin_mask_pred = bin_mask_pred.int() + # Quickfix to avoid num. instability. + bin_mask_pred = bin_mask_pred * 1e-3 + mask_score = (bin_mask_pred * mask_pred).sum(-1).sum(-1) / ( + (bin_mask_pred).sum(-1).sum(-1) + 1e-5 + ) # add EPS to avoid division by 0 + # Multiply mask scores to class scores for final score + scores = scores * mask_score # B x Q + + # Filter based on the scores greather than threshold + if threshold > 0: + filter_mask = scores > threshold + filter_mask = filter_mask.nonzero(as_tuple=True) + scores = torch.gather(scores, dim=1, index=filter_mask[1].unsqueeze(0)) + labels = torch.gather(labels, dim=1, index=filter_mask[1].unsqueeze(0)) + bin_mask_pred = torch.gather( + bin_mask_pred, + dim=1, + index=filter_mask[1].unsqueeze(0).unsqueeze(-1).unsqueeze(-1).expand(-1, -1, *bin_mask_pred.shape[-2:]), + ) # B x top_k_masks x H x W + + bin_mask_pred = bin_mask_pred.detach().cpu() + scores = scores.detach().cpu() + labels = labels.detach().cpu() + + for i in range(batch_size): + if len(bin_mask_pred[i]) == 0: + results.append(FocoosDetections(detections=[])) + continue + # interpolate mask pred to original size + bin_mask_pred_resized = retry_if_cuda_oom(interpolate_image)( + bin_mask_pred[i].float(), image_sizes[i] + ).bool() + + box_pred = masks_to_xyxy(bin_mask_pred_resized.numpy()) + py_box_pred = box_pred.tolist() + + py_scores = scores[i].tolist() + py_labels = labels[i].tolist() + py_mask_pred = bin_mask_pred_resized.numpy() + + results.append( + FocoosDetections( + detections=[ + FocoosDet( + bbox=py_bp, + conf=py_s, + cls_id=py_l, + mask=binary_mask_to_base64(trim_mask(py_mp, py_bp)), + label=class_names[py_l] if class_names else None, + ) + for py_bp, py_s, py_l, py_mp in zip(py_box_pred, py_scores, py_labels, py_mask_pred) + ] + ) + ) + + return results + + def export_postprocess( + self, + output: Union[list[torch.Tensor], list[np.ndarray]], + inputs: Union[ + torch.Tensor, + np.ndarray, + list[np.ndarray], + list[torch.Tensor], + ], + threshold: Optional[float] = None, + class_names: list[str] = [], + **kwargs, + ) -> list[FocoosDetections]: + masks = output[0] + logits = output[1] + device = torch.device("cuda" if torch.cuda.is_available() else "cpu") + if isinstance(logits, np.ndarray): + logits = torch.from_numpy(logits) + if isinstance(masks, np.ndarray): + masks = torch.from_numpy(masks) + + model_output = MaskFormerModelOutput(logits=logits.to(device), masks=masks.to(device), loss=None) + return self.postprocess( + model_output, + inputs, + class_names, + threshold=threshold, + **kwargs, + ) + + def get_dynamic_axes(self) -> DynamicAxes: + return DynamicAxes( + input_names=["images"], + output_names=["masks", "logits"], + dynamic_axes={ + "images": {0: "batch", 2: "height", 3: "width"}, + }, + ) diff --git a/focoos/models/focoos_model.py b/focoos/models/focoos_model.py new file mode 100644 index 00000000..df50fc98 --- /dev/null +++ b/focoos/models/focoos_model.py @@ -0,0 +1,608 @@ +import os +from datetime import datetime +from pathlib import Path +from typing import Literal, Optional, Tuple, Union +from urllib.parse import urlparse + +import numpy as np +import torch +from PIL import Image + +from focoos.data.datasets.map_dataset import MapDataset +from focoos.hub.api_client import ApiClient +from focoos.hub.focoos_hub import FocoosHUB +from focoos.infer.infer_model import InferModel +from focoos.models.base_model import BaseModelNN +from focoos.ports import ( + MODELS_DIR, + ArtifactName, + ExportFormat, + FocoosDetections, + LatencyMetrics, + ModelInfo, + ModelStatus, + RuntimeType, + TrainerArgs, + TrainingInfo, +) +from focoos.processor.processor_manager import ProcessorManager +from focoos.utils.distributed.dist import launch +from focoos.utils.env import TORCH_VERSION +from focoos.utils.logger import get_logger +from focoos.utils.system import get_cpu_name, get_focoos_version, get_system_info + +logger = get_logger("FocoosModel") + + +class ExportableModel(torch.nn.Module): + """A wrapper class for making models exportable to different formats. + + This class wraps a BaseModelNN model to make it compatible with export formats + like ONNX and TorchScript by handling the output formatting. + + Args: + model: The base model to wrap for export. + device: The device to move the model to. Defaults to "cuda". + """ + + def __init__(self, model: BaseModelNN, device="cuda"): + """Initialize the ExportableModel. + + Args: + model: The base model to wrap for export. + device: The device to move the model to. Defaults to "cuda". + """ + super().__init__() + self.model = model.eval().to(device) + + def forward(self, x): + """Forward pass through the wrapped model. + + Args: + x: Input tensor to pass through the model. + + Returns: + Model output converted to tuple format for export compatibility. + """ + return self.model(x).to_tuple() + + +class FocoosModel: + """Main model class for Focoos computer vision models. + + This class provides a high-level interface for training, testing, exporting, + and running inference with Focoos models. It handles model configuration, + weight loading, preprocessing, and postprocessing. + + Args: + model: The underlying neural network model. + model_info: Metadata and configuration information for the model. + """ + + def __init__(self, model: BaseModelNN, model_info: ModelInfo): + """Initialize the FocoosModel. + + Args: + model: The underlying neural network model. + model_info: Metadata and configuration information for the model. + """ + self.model = model + self.model_info = model_info + self.processor = ProcessorManager.get_processor(self.model_info.model_family, self.model_info.config) + if self.model_info.weights_uri: + self._load_weights() + else: + logger.warning(f"โš ๏ธ Model {self.model_info.name} has no pretrained weights") + + def __str__(self): + """Return string representation of the model. + + Returns: + String containing model name and family. + """ + return f"{self.model_info.name} ({self.model_info.model_family.value})" + + def __repr__(self): + """Return detailed string representation of the model. + + Returns: + String containing model name and family. + """ + return f"{self.model_info.name} ({self.model_info.model_family.value})" + + def _setup_model_for_training(self, train_args: TrainerArgs, data_train: MapDataset, data_val: MapDataset): + """Set up the model and metadata for training. + + This method configures the model information with training parameters, + device information, dataset metadata, and initializes training status. + + Args: + train_args: Training configuration arguments. + data_train: Training dataset. + data_val: Validation dataset. + """ + device = get_cpu_name() + system_info = get_system_info() + if system_info.gpu_info and system_info.gpu_info.devices and len(system_info.gpu_info.devices) > 0: + device = system_info.gpu_info.devices[0].gpu_name + self.model_info.ref = None + + self.model_info.train_args = train_args # type: ignore + self.model_info.val_dataset = data_val.dataset.metadata.name + self.model_info.val_metrics = None + self.model_info.classes = data_val.dataset.metadata.classes + self.model_info.focoos_version = get_focoos_version() + self.model_info.status = ModelStatus.TRAINING_STARTING + self.model_info.updated_at = datetime.now().isoformat() + self.model_info.latency = [] + self.model_info.metrics = None + self.model_info.training_info = TrainingInfo( + instance_device=device, + main_status=ModelStatus.TRAINING_STARTING, + start_time=datetime.now().isoformat(), + status_transitions=[ + dict( + status=ModelStatus.TRAINING_STARTING, + timestamp=datetime.now().isoformat(), + ) + ], + ) + + self.model_info.classes = data_train.dataset.metadata.classes + self.model_info.config["num_classes"] = len(data_train.dataset.metadata.classes) + self._reload_model() + self.model_info.name = train_args.run_name.strip() + assert self.model_info.task == data_train.dataset.metadata.task, "Task mismatch between model and dataset." + + def train(self, args: TrainerArgs, data_train: MapDataset, data_val: MapDataset, hub: Optional[FocoosHUB] = None): + """Train the model on the provided datasets. + + This method handles both single-GPU and multi-GPU distributed training. + It sets up the model for training, optionally syncs with Focoos Hub, + and manages the training process. + + Args: + args: Training configuration and hyperparameters. + data_train: Training dataset containing images and annotations. + data_val: Validation dataset for model evaluation. + hub: Optional Focoos Hub instance for model syncing. + + Raises: + AssertionError: If task mismatch between model and dataset. + AssertionError: If number of classes mismatch between model and dataset. + AssertionError: If num_gpus is 0 (GPU training is required). + FileNotFoundError: If training artifacts are not found after completion. + """ + from focoos.trainer.trainer import run_train + + self._setup_model_for_training(args, data_train, data_val) + assert self.model_info.task == data_train.dataset.metadata.task, "Task mismatch between model and dataset." + assert self.model_info.config["num_classes"] == data_train.dataset.metadata.num_classes, ( + "Number of classes mismatch between model and dataset." + ) + remote_model = None + if args.sync_to_hub: + hub = hub or FocoosHUB() + remote_model = hub.new_model(self.model_info) + + self.model_info.ref = remote_model.ref + logger.info(f"Model {self.model_info.name} created in hub with ref {self.model_info.ref}") + + assert args.num_gpus, "Training without GPUs is not supported. num_gpus must be greater than 0" + if args.num_gpus > 1: + launch( + run_train, + args.num_gpus, + dist_url="auto", + args=(args, data_train, data_val, self.model, self.processor, self.model_info, remote_model), + ) + + logger.info("Training done, resuming main process.") + # here i should restore the best model and config since in DDP it is not updated + final_folder = os.path.join(args.output_dir, args.run_name) + model_path = os.path.join(final_folder, ArtifactName.WEIGHTS) + metadata_path = os.path.join(final_folder, ArtifactName.INFO) + + if not os.path.exists(model_path): + raise FileNotFoundError(f"Training did not end correctly, model file not found at {model_path}") + if not os.path.exists(metadata_path): + raise FileNotFoundError(f"Training did not end correctly, metadata file not found at {metadata_path}") + self.model_info = ModelInfo.from_json(metadata_path) + + logger.info(f"Reloading weights from {self.model_info.weights_uri}") + self._reload_model() + else: + run_train(args, data_train, data_val, self.model, self.processor, self.model_info, remote_model) + + def test(self, args: TrainerArgs, data_test: MapDataset): + """Test the model on the provided test dataset. + + This method evaluates the model performance on a test dataset, + supporting both single-GPU and multi-GPU testing. + + Args: + args: Test configuration arguments. + data_test: Test dataset for model evaluation. + + Raises: + AssertionError: If task mismatch between model and dataset. + AssertionError: If num_gpus is 0 (GPU testing is required). + """ + from focoos.trainer.trainer import run_test + + self.model_info.val_dataset = data_test.dataset.metadata.name + self.model_info.val_metrics = None + self.model_info.classes = data_test.dataset.metadata.classes + self.model_info.config["num_classes"] = data_test.dataset.metadata.num_classes + assert self.model_info.task == data_test.dataset.metadata.task, "Task mismatch between model and dataset." + + assert args.num_gpus, "Testing without GPUs is not supported. num_gpus must be greater than 0" + if args.num_gpus > 1: + launch( + run_test, + args.num_gpus, + dist_url="auto", + args=(args, data_test, self.model, self.processor, self.model_info), + ) + logger.info("Testing done, resuming main process.") + # here i should restore the best model and config since in DDP it is not updated + final_folder = os.path.join(args.output_dir, args.run_name) + metadata_path = os.path.join(final_folder, ArtifactName.INFO) + self.model_info = ModelInfo.from_json(metadata_path) + else: + run_test(args, data_test, self.model, self.processor, self.model_info) + + @property + def device(self): + """Get the device where the model is located. + + Returns: + The device (CPU or CUDA) where the model is currently located. + """ + return self.model.device + + @property + def resolution(self): + """Get the input resolution of the model. + + Returns: + The input image resolution expected by the model. + """ + return self.model_info.config["resolution"] + + @property + def config(self) -> dict: + """Get the model configuration. + + Returns: + Dictionary containing the model configuration parameters. + """ + return self.model_info.config + + @property + def classes(self): + """Get the class names the model can predict. + + Returns: + List of class names that the model was trained to recognize. + """ + return self.model_info.classes + + @property + def task(self): + """Get the computer vision task type. + + Returns: + The type of computer vision task (e.g., detection, classification). + """ + return self.model_info.task + + def export( + self, + runtime_type: RuntimeType = RuntimeType.ONNX_CUDA32, + onnx_opset: int = 17, + out_dir: Optional[str] = None, + device: Literal["cuda", "cpu"] = "cuda", + overwrite: bool = False, + image_size: Optional[int] = None, + ) -> InferModel: + """Export the model to different runtime formats. + + This method exports the model to formats like ONNX or TorchScript + for deployment and inference optimization. + + Args: + runtime_type: Target runtime format for export. + onnx_opset: ONNX opset version to use for ONNX export. + out_dir: Output directory for exported model. If None, uses default location. + device: Device to use for export ("cuda" or "cpu"). + overwrite: Whether to overwrite existing exported model files. + image_size: Custom image size for export. If None, uses model's default size. + + Returns: + InferModel instance for the exported model. + + Raises: + ValueError: If unsupported PyTorch version or export format. + """ + if device == "cuda" and not torch.cuda.is_available(): + device = "cpu" + logger.warning("CUDA is not available. Using CPU for export.") + if out_dir is None: + out_dir = os.path.join(MODELS_DIR, self.model_info.ref or self.model_info.name) + + format = runtime_type.to_export_format() + exportable_model = ExportableModel(self.model, device=device) + os.makedirs(out_dir, exist_ok=True) + if image_size is None: + data = 128 * torch.randn(1, 3, self.model_info.im_size, self.model_info.im_size).to(device) + else: + data = 128 * torch.randn(1, 3, image_size, image_size).to(device) + self.model_info.im_size = image_size + + export_model_name = ArtifactName.ONNX if format == ExportFormat.ONNX else ArtifactName.PT + _out_file = os.path.join(out_dir, export_model_name) + + dynamic_axes = self.processor.get_dynamic_axes() + + # Hack to warm up the model and record the spacial shapes if needed + self.model(data) + + if not overwrite and os.path.exists(_out_file): + logger.info(f"Model file {_out_file} already exists. Set overwrite to True to overwrite.") + return InferModel(model_dir=out_dir, runtime_type=runtime_type) + + if format == "onnx": + with torch.no_grad(): + logger.info("๐Ÿš€ Exporting ONNX model..") + if TORCH_VERSION >= (2, 5): + exp_program = torch.onnx.export( + exportable_model, + (data,), + f=_out_file, + opset_version=onnx_opset, + verbose=False, + verify=True, + dynamo=False, + external_data=False, # model weights external to model + input_names=dynamic_axes.input_names, + output_names=dynamic_axes.output_names, + dynamic_axes=dynamic_axes.dynamic_axes, + do_constant_folding=True, + export_params=True, + # dynamic_shapes={ + # "x": { + # 0: torch.export.Dim("batch", min=1, max=64), + # #2: torch.export.Dim("height", min=18, max=4096), + # #3: torch.export.Dim("width", min=18, max=4096), + # } + # }, + ) + elif TORCH_VERSION >= (2, 0): + torch.onnx.export( + exportable_model, + (data,), + f=_out_file, + opset_version=onnx_opset, + verbose=False, + input_names=dynamic_axes.input_names, + output_names=dynamic_axes.output_names, + dynamic_axes=dynamic_axes.dynamic_axes, + do_constant_folding=True, + export_params=True, + ) + else: + raise ValueError(f"Unsupported Torch version: {TORCH_VERSION}. Install torch 2.x") + # if exp_program is not None: + # exp_program.optimize() + # exp_program.save(_out_file) + logger.info(f"โœ… Exported {format} model to {_out_file}") + + elif format == "torchscript": + with torch.no_grad(): + logger.info("๐Ÿš€ Exporting TorchScript model..") + exp_program = torch.jit.trace(exportable_model, data) + if exp_program is not None: + _out_file = os.path.join(out_dir, ArtifactName.PT) + torch.jit.save(exp_program, _out_file) + logger.info(f"โœ… Exported {format} model to {_out_file} ") + else: + raise ValueError(f"Failed to export {format} model") + + # Fixme: this may override the model_info with the one from the exportable model + self.model_info.dump_json(os.path.join(out_dir, ArtifactName.INFO)) + return InferModel(model_dir=out_dir, runtime_type=runtime_type) + + def __call__( + self, + inputs: Union[ + torch.Tensor, + np.ndarray, + Image.Image, + list[Image.Image], + list[np.ndarray], + list[torch.Tensor], + ], + **kwargs, + ) -> FocoosDetections: + """Run inference on input images. + + This method performs end-to-end inference including preprocessing, + model forward pass, and postprocessing to return detections. + + Args: + inputs: Input images in various formats (PIL, numpy, torch tensor, or lists). + **kwargs: Additional arguments passed to postprocessing. + + Returns: + FocoosDetections containing the detection results. + """ + model = self.model.eval() + processor = self.processor.eval() + try: + model = model.cuda() + except Exception: + logger.warning("Unable to use CUDA") + images, _ = processor.preprocess( + inputs, + device=model.device, + dtype=model.dtype, + image_size=self.model_info.im_size, + ) # second output is targets that we're not using + with torch.no_grad(): + try: + with torch.autocast(device_type="cuda", dtype=torch.float16): + output = model.forward(images) + except Exception: + output = model.forward(images) + class_names = self.model_info.classes + output_fdet = processor.postprocess(output, inputs, class_names=class_names, **kwargs) + + # FIXME: we don't support batching yet + return output_fdet[0] + + def _reload_model(self): + """Reload the model with updated configuration. + + This method recreates the model instance with the current configuration + and reloads the weights. Used when configuration changes during training. + """ + from focoos.model_manager import ConfigManager # here to avoid circular import + + torch.cuda.empty_cache() + model_class = self.model.__class__ + # without the next line, the inner config may be not a ModelConfig but a dict + config = ConfigManager.from_dict(self.model_info.model_family, self.model_info.config) + self.model_info.config = config + model = model_class(config) + self.model = model + self._load_weights() + + def _load_weights(self) -> int: + """Load model weights from the specified URI. + + This method loads the model weights from either a local path or a remote URL, + depending on the value of `self.model_info.weights_uri`. If the weights are remote, + they are downloaded to a local directory. The method then loads the weights into + the model, allowing for missing or unexpected keys (non-strict loading). + + Returns: + The total number of missing or unexpected keys encountered during loading. + Returns 0 if no weights are loaded or an error occurs. + + Raises: + FileNotFoundError: If the weights file cannot be found at the specified path. + """ + if not self.model_info.weights_uri: + logger.warning(f"โš ๏ธ Model {self.model_info.name} has no pretrained weights") + return 0 + + # Determine if weights are remote or local + parsed_uri = urlparse(self.model_info.weights_uri) + is_remote = bool(parsed_uri.scheme and parsed_uri.netloc) + + # Get weights path + if is_remote: + logger.info(f"Downloading weights from remote URL: {self.model_info.weights_uri}") + model_dir = Path(MODELS_DIR) / self.model_info.name + weights_path = ApiClient().download_ext_file( + self.model_info.weights_uri, str(model_dir), skip_if_exists=True + ) + else: + logger.info(f"Using weights from local path: {self.model_info.weights_uri}") + weights_path = self.model_info.weights_uri + + try: + if not os.path.exists(weights_path): + raise FileNotFoundError(f"Weights file not found: {weights_path}") + + # Load weights and extract model state if needed + state_dict = torch.load(weights_path, map_location="cpu", weights_only=True) + weights_dict = state_dict.get("model", state_dict) if isinstance(state_dict, dict) else state_dict + + except Exception as e: + logger.error(f"Error loading weights for {self.model_info.name}: {str(e)}") + return 0 + + incompatible = self.model.load_state_dict(weights_dict, strict=False) + return len(incompatible.missing_keys) + len(incompatible.unexpected_keys) + + def benchmark( + self, + iterations: int = 50, + size: Optional[Union[int, Tuple[int, int]]] = None, + device: Literal["cuda", "cpu"] = "cuda", + ) -> LatencyMetrics: + """Benchmark the model's inference performance. + + This method measures the raw model inference latency without + preprocessing and postprocessing overhead. + + Args: + iterations: Number of iterations to run for benchmarking. + size: Input image size. If None, uses model's default size. + device: Device to run benchmarking on ("cuda" or "cpu"). + + Returns: + LatencyMetrics containing performance statistics. + """ + self.model.eval() + + if size is None: + size = self.model_info.im_size + if isinstance(size, int): + size = (size, size) + model = self.model.to(device) + metrics = model.benchmark(size=size, iterations=iterations) + return metrics + + def end2end_benchmark( + self, iterations: int = 50, size: Optional[int] = None, device: Literal["cuda", "cpu"] = "cuda" + ) -> LatencyMetrics: + """Benchmark the complete end-to-end inference pipeline. + + This method measures the full inference latency including preprocessing, + model forward pass, and postprocessing steps. + + Args: + iterations: Number of iterations to run for benchmarking. + size: Input image size. If None, uses model's default size. + device: Device to run benchmarking on ("cuda" or "cpu"). + + Returns: + LatencyMetrics containing end-to-end performance statistics. + """ + if size is None: + size = self.model_info.im_size + + try: + model = self.model.cuda() + except Exception: + logger.warning("Unable to use CUDA") + logger.info(f"โฑ๏ธ Benchmarking latency on {model.device}, size: {size}x{size}..") + # warmup + data = 128 * torch.randn(1, 3, size, size).to(model.device) + + durations = [] + for _ in range(iterations): + start = torch.cuda.Event(enable_timing=True) + end = torch.cuda.Event(enable_timing=True) + start.record(stream=torch.cuda.Stream()) + _ = self(data) + end.record(stream=torch.cuda.Stream()) + torch.cuda.synchronize() + durations.append(start.elapsed_time(end)) + + durations = np.array(durations) + metrics = LatencyMetrics( + fps=int(1000 / durations.mean()), + engine=f"torch.{self.model.device}", + mean=round(durations.mean().astype(float), 3), + max=round(durations.max().astype(float), 3), + min=round(durations.min().astype(float), 3), + std=round(durations.std().astype(float), 3), + im_size=size, + device=str(self.model.device), + ) + logger.info(f"๐Ÿ”ฅ FPS: {metrics.fps} Mean latency: {metrics.mean} ms ") + return metrics diff --git a/focoos/nn/backbone/__init__.py b/focoos/nn/backbone/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/focoos/nn/backbone/base.py b/focoos/nn/backbone/base.py new file mode 100644 index 00000000..d8bdcc7e --- /dev/null +++ b/focoos/nn/backbone/base.py @@ -0,0 +1,101 @@ +# Copyright (c) Facebook, Inc. and its affiliates. +# From Detectron2 +# Copyright (c) Focoos AI S.r.L. +from abc import ABCMeta, abstractmethod +from dataclasses import dataclass +from typing import Dict, Optional + +import torch.nn as nn + +from focoos.ports import DictClass + +__all__ = ["BaseBackbone", "ShapeSpec"] + + +@dataclass +class ShapeSpec: + """ + A simple structure that contains basic shape specification about a tensor. + It is often used as the auxiliary inputs/outputs of models, + to complement the lack of shape inference ability among pytorch modules. + """ + + channels: Optional[int] = None + height: Optional[int] = None + width: Optional[int] = None + stride: Optional[int] = None + + +@dataclass +class BackboneConfig(DictClass): + use_pretrained: bool = False + backbone_url: Optional[str] = None # only used if use_pretrained is True + model_type: str = "" + + +class BaseBackbone(nn.Module, metaclass=ABCMeta): + """ + Abstract base class for network backbones. + """ + + def __init__(self, config: BackboneConfig): + """ + The `__init__` method of any subclass can specify its own set of arguments. + """ + super().__init__() + self.config = config + + @abstractmethod + def forward(self): + """ + Subclasses must override this method, but adhere to the same return type. + + Returns: + dict[str->Tensor]: mapping from feature name (e.g., "res2") to tensor + """ + pass + + @property + def size_divisibility(self) -> int: + """ + Some backbones require the input height and width to be divisible by a + specific integer. This is typically true for encoder / decoder type networks + with lateral connection (e.g., FPN) for which feature maps need to match + dimension in the "bottom up" and "top down" paths. Set to 0 if no specific + input size divisibility is required. + """ + return 0 + + @property + def padding_constraints(self) -> Dict[str, int]: + """ + This property is a generalization of size_divisibility. Some backbones and training + recipes require specific padding constraints, such as enforcing divisibility by a specific + integer (e.g., FPN) or padding to a square (e.g., ViTDet with large-scale jitter + in :paper:vitdet). `padding_constraints` contains these optional items like: + { + "size_divisibility": int, + "square_size": int, + # Future options are possible + } + `size_divisibility` will read from here if presented and `square_size` indicates the + square padding size if `square_size` > 0. + + TODO: use type of Dict[str, int] to avoid torchscipt issues. The type of padding_constraints + could be generalized as TypedDict (Python 3.8+) to support more types in the future. + """ + return {} + + def output_shape(self): + """ + Returns: + dict[str->ShapeSpec] + """ + # this is a backward-compatible default + return { + name: ShapeSpec( + channels=self._out_feature_channels[name], + stride=self._out_feature_strides[name], + ) + for name in self._out_features + } diff --git a/focoos/nn/backbone/build.py b/focoos/nn/backbone/build.py new file mode 100644 index 00000000..4a9ca057 --- /dev/null +++ b/focoos/nn/backbone/build.py @@ -0,0 +1,8 @@ +from focoos.nn.backbone.base import BackboneConfig + + +def load_backbone(config: BackboneConfig): + # to avoid circular import + from focoos.model_manager import BackboneManager + + return BackboneManager.from_config(config) diff --git a/focoos/nn/backbone/convnextv2.py b/focoos/nn/backbone/convnextv2.py new file mode 100644 index 00000000..d4c0e9b1 --- /dev/null +++ b/focoos/nn/backbone/convnextv2.py @@ -0,0 +1,203 @@ +from dataclasses import dataclass +from typing import Optional, Tuple + +import torch +import torch.nn as nn +from torch.nn.init import trunc_normal_ + +from focoos.nn.layers.misc import DropPath +from focoos.nn.layers.norm import LayerNorm +from focoos.utils.logger import get_logger + +from .base import BackboneConfig, BaseBackbone, ShapeSpec + +logger = get_logger("Backbone") + + +class GRN(nn.Module): + """GRN (Global Response Normalization) layer""" + + def __init__(self, dim): + super().__init__() + self.gamma = nn.Parameter(torch.zeros(1, 1, 1, dim)) + self.beta = nn.Parameter(torch.zeros(1, 1, 1, dim)) + + def forward(self, x): + Gx = torch.norm(x, p=2, dim=(1, 2), keepdim=True) + Nx = Gx / (Gx.mean(dim=-1, keepdim=True) + 1e-6) + return self.gamma * (x * Nx) + self.beta + x + + +class Block(nn.Module): + """ConvNeXtV2 Block. + + Args: + dim (int): Number of input channels. + drop_path (float): Stochastic depth rate. Default: 0.0 + """ + + def __init__(self, dim, drop_path=0.0): + super().__init__() + self.dwconv = nn.Conv2d(dim, dim, kernel_size=7, padding=3, groups=dim) # depthwise conv + self.norm = LayerNorm(dim, eps=1e-6) + self.pwconv1 = nn.Linear(dim, 4 * dim) # pointwise/1x1 convs, implemented with linear layers + self.act = nn.GELU() + self.grn = GRN(4 * dim) + self.pwconv2 = nn.Linear(4 * dim, dim) + self.drop_path = DropPath(drop_path) if drop_path > 0.0 else nn.Identity() + + def forward(self, x): + input = x + x = self.dwconv(x) + x = x.permute(0, 2, 3, 1) # (N, C, H, W) -> (N, H, W, C) + x = self.norm(x) + x = self.pwconv1(x) + x = self.act(x) + x = self.grn(x) + x = self.pwconv2(x) + x = x.permute(0, 3, 1, 2) # (N, H, W, C) -> (N, C, H, W) + + x = input + self.drop_path(x) + return x + + +CONFIGS = { + "atto": { + "depths": [2, 2, 6, 2], + "embed_dims": [40, 80, 160, 320], + "url": "https://public.focoos.ai/pretrained_models/backbones/convnextv2_atto.pth", + }, + "femto": { + "depths": [2, 2, 6, 2], + "embed_dims": [48, 96, 192, 384], + "url": "https://public.focoos.ai/pretrained_models/backbones/convnextv2_femto.pth", + }, + "pico": { + "depths": [2, 2, 6, 2], + "embed_dims": [64, 128, 256, 512], + "url": "https://public.focoos.ai/pretrained_models/backbones/convnextv2_pico.pth", + }, + "nano": { + "depths": [2, 2, 8, 2], + "embed_dims": [80, 160, 320, 640], + "url": "https://public.focoos.ai/pretrained_models/backbones/convnextv2_nano.pth", + }, + "tiny": { + "depths": [3, 3, 9, 3], + "embed_dims": [96, 192, 384, 768], + "url": "https://public.focoos.ai/pretrained_models/backbones/convnextv2_tiny.pth", + }, + "base": { + "depths": [3, 3, 27, 3], + "embed_dims": [128, 256, 512, 1024], + "url": "https://public.focoos.ai/pretrained_models/backbones/convnextv2_base.pth", + }, + "large": { + "depths": [3, 3, 27, 3], + "embed_dims": [192, 384, 768, 1536], + "url": "https://public.focoos.ai/pretrained_models/backbones/convnextv2_large.pth", + }, +} + + +@dataclass +class ConvNeXtV2Config(BackboneConfig): + """ConvNeXt V2 configuration""" + + model_type: str = "convnextv2" + model_size: Optional[str] = "atto" + drop_path_rate: float = 0.0 + depths: Optional[Tuple[int, ...]] = None + embed_dims: Optional[Tuple[int, ...]] = None + + +class ConvNeXtV2(BaseBackbone): + """ConvNeXt V2 + + Args: + config: Configuration object containing model parameters + """ + + def __init__(self, config: ConvNeXtV2Config): + super().__init__(config) + in_chans = 3 + + if config.model_size: + depths = CONFIGS[config.model_size]["depths"] + dims = CONFIGS[config.model_size]["embed_dims"] + backbone_url = config.backbone_url or CONFIGS[config.model_size]["url"] + else: + backbone_url = config.backbone_url + depths = config.depths + dims = config.embed_dims + assert depths is not None and dims is not None, ( + "depths and embed_dims must be provided if model_size is not provided" + ) + drop_path_rate = config.drop_path_rate + + self.depths = depths + self.downsample_layers = nn.ModuleList() # stem and 3 intermediate downsampling conv layers + stem = nn.Sequential( + nn.Conv2d(in_chans, dims[0], kernel_size=4, stride=4), + LayerNorm(dims[0], eps=1e-6, data_format="channels_first"), + ) + self.downsample_layers.append(stem) + for i in range(3): + downsample_layer = nn.Sequential( + LayerNorm(dims[i], eps=1e-6, data_format="channels_first"), + nn.Conv2d(dims[i], dims[i + 1], kernel_size=2, stride=2), + ) + self.downsample_layers.append(downsample_layer) + + self.stages = nn.ModuleList() # 4 feature resolution stages, each consisting of multiple residual blocks + dp_rates = [x.item() for x in torch.linspace(0, drop_path_rate, sum(depths))] + cur = 0 + for i in range(4): + stage = nn.Sequential(*[Block(dim=dims[i], drop_path=dp_rates[cur + j]) for j in range(depths[i])]) + self.stages.append(stage) + cur += depths[i] + + if config.use_pretrained and backbone_url: + state = torch.hub.load_state_dict_from_url(backbone_url) + self.load_state_dict(state) + logger.info(f"Load ConvNeXtV2{config.model_size} state_dict") + + self._out_features = ["res2", "res3", "res4", "res5"] + + self._out_feature_strides = { + "res2": 4, + "res3": 8, + "res4": 16, + "res5": 32, + } + self._out_feature_channels = { + "res2": dims[0], + "res3": dims[1], + "res4": dims[2], + "res5": dims[3], + } + + self.apply(self._init_weights) + + def _init_weights(self, m): + if isinstance(m, (nn.Conv2d, nn.Linear)): + trunc_normal_(m.weight, std=0.02) + if m.bias is not None: + nn.init.constant_(m.bias, 0) + + def forward(self, x): + outs = {} + for i in range(4): + x = self.downsample_layers[i](x) + x = self.stages[i](x) + outs["res{}".format(i + 2)] = x + return outs + + def output_shape(self): + return { + name: ShapeSpec( + channels=self._out_feature_channels[name], + stride=self._out_feature_strides[name], + ) + for name in self._out_features + } diff --git a/focoos/nn/backbone/mobilenet_v2.py b/focoos/nn/backbone/mobilenet_v2.py new file mode 100644 index 00000000..8c08e34f --- /dev/null +++ b/focoos/nn/backbone/mobilenet_v2.py @@ -0,0 +1,262 @@ +from dataclasses import dataclass +from typing import Optional, Tuple + +import torch +import torch.nn as nn + +from focoos.nn.layers.conv import Conv2d +from focoos.nn.layers.norm import get_norm +from focoos.utils.logger import get_logger + +from .base import BackboneConfig, BaseBackbone + +logger = get_logger("Backbone") + + +class InvertedResidual(nn.Module): + """InvertedResidual block for MobileNetV2. + + Args: + in_channels (int): The input channels of the InvertedResidual block. + out_channels (int): The output channels of the InvertedResidual block. + stride (int): Stride of the middle (first) 3x3 convolution. + expand_ratio (int): Adjusts number of channels of the hidden layer + in InvertedResidual by this amount. + dilation (int): Dilation rate of depthwise conv. Default: 1 + act (dict): Config dict for activation layer. + Default: dict(type='ReLU6'). + + Returns: + Tensor: The output tensor. + """ + + def __init__( + self, + in_channels, + out_channels, + stride, + expand_ratio, + dilation=1, + norm="BN", + activation=None, + **kwargs, + ): + super().__init__() + self.stride = stride + assert stride in [1, 2], f"stride must in [1, 2]. But received {stride}." + self.use_res_connect = self.stride == 1 and in_channels == out_channels + hidden_dim = int(round(in_channels * expand_ratio)) + + layers = [] + if expand_ratio != 1: + layers.append( + Conv2d( + in_channels=in_channels, + out_channels=hidden_dim, + kernel_size=1, + bias="", + norm=get_norm(norm, hidden_dim), + activation=activation, + **kwargs, + ) + ) + layers.extend( + [ + Conv2d( + in_channels=hidden_dim, + out_channels=hidden_dim, + kernel_size=3, + stride=stride, + padding=dilation, + dilation=dilation, + groups=hidden_dim, + bias="", + norm=get_norm(norm, hidden_dim), + activation=activation, + **kwargs, + ), + Conv2d( + in_channels=hidden_dim, + out_channels=out_channels, + kernel_size=1, + bias="", + norm=get_norm(norm, out_channels), + activation=activation, + **kwargs, + ), + ] + ) + self.conv = nn.Sequential(*layers) + + def forward(self, x): + if self.use_res_connect: + return x + self.conv(x) + else: + return self.conv(x) + + +@dataclass +class MobileNetV2Config(BackboneConfig): + """MobileNetV2 configuration""" + + in_chans: int = 3 + widen_factor: float = 1.0 + strides: Tuple[int, ...] = (1, 2, 2, 2, 1, 2, 1) + dilations: Tuple[int, ...] = (1, 1, 1, 1, 1, 1, 1) + frozen_stages: int = -1 + norm: str = "BN" + model_type: str = "mobilenet_v2" + backbone_url: Optional[str] = "https://public.focoos.ai/pretrained_models/backbones/mobilenet_v2.pth" + + +class MobileNetV2(BaseBackbone): + """MobileNetV2 backbone. + + This backbone is the implementation of + `MobileNetV2: Inverted Residuals and Linear Bottlenecks + `_. + + Args: + widen_factor (float): Width multiplier, multiply number of + channels in each layer by this amount. Default: 1.0. + strides (Sequence[int], optional): Strides of the first block of each + layer. If not specified, default config in ``arch_setting`` will + be used. + dilations (Sequence[int]): Dilation of each layer. + frozen_stages (int): Stages to be frozen (all param fixed). + Default: -1, which means not freezing any parameters. + norm (dict): Config dict for normalization layer. + Default: dict(type='BN'). + """ + + # Parameters to build layers. 3 parameters are needed to construct a + # layer, from left to right: expand_ratio, channel, num_blocks. + arch_settings = [ + [1, 16, 1], + [6, 24, 2], + [6, 32, 3], + [6, 64, 4], + [6, 96, 3], + [6, 160, 3], + [6, 320, 1], + ] + + def __init__( + self, + config: MobileNetV2Config, + ): + super().__init__(config) + self.widen_factor = config.widen_factor + self.strides = config.strides + self.dilations = config.dilations + assert len(config.strides) == len(config.dilations) == len(self.arch_settings) + + if config.frozen_stages not in range(-1, 7): + raise ValueError(f"frozen_stages must be in range(-1, 7). But received {config.frozen_stages}") + self.frozen_stages = config.frozen_stages + self.norm = config.norm + self.act = nn.functional.relu6 + + self.in_channels = int(32 * config.widen_factor) + + self._out_feature_strides = {} + self._out_feature_channels = {} + self._out_features = ["res2", "res3", "res4", "res5"] + + self.conv1 = Conv2d( + in_channels=config.in_chans, + out_channels=self.in_channels, + kernel_size=3, + stride=2, + padding=1, + bias="", + norm=get_norm(config.norm, self.in_channels), + activation=self.act, + ) + + self.layers = [] + self.layer_to_res = { + "layer2": "res2", + "layer3": "res3", + "layer5": "res4", + "layer7": "res5", + } + tot_stride = 1 + + for i, layer_cfg in enumerate(self.arch_settings): + expand_ratio, channel, num_blocks = layer_cfg + stride = self.strides[i] + tot_stride = tot_stride * stride + dilation = self.dilations[i] + out_channels = int(channel * self.widen_factor) + inverted_res_layer = self.make_layer( + out_channels=out_channels, + num_blocks=num_blocks, + stride=stride, + dilation=dilation, + expand_ratio=expand_ratio, + ) + layer_name = f"layer{i + 1}" + if layer_name in self.layer_to_res: + res_block = self.layer_to_res[layer_name] + self._out_feature_strides[res_block] = tot_stride + self._out_feature_channels[res_block] = out_channels + self.add_module(layer_name, inverted_res_layer) + self.layers.append(layer_name) + + if config.use_pretrained and config.backbone_url: + state = torch.hub.load_state_dict_from_url(config.backbone_url) + self.load_state_dict(state) + logger.info("Load MobileNetV2 state_dict") + + def make_layer(self, out_channels, num_blocks, stride, dilation, expand_ratio): + """Stack InvertedResidual blocks to build a layer for MobileNetV2. + + Args: + out_channels (int): out_channels of block. + num_blocks (int): Number of blocks. + stride (int): Stride of the first block. + dilation (int): Dilation of the first block. + expand_ratio (int): Expand the number of channels of the + hidden layer in InvertedResidual by this ratio. + """ + layers = [] + for i in range(num_blocks): + layers.append( + InvertedResidual( + self.in_channels, + out_channels, + stride if i == 0 else 1, + expand_ratio=expand_ratio, + dilation=dilation if i == 0 else 1, + norm=self.norm, + activation=self.act, + ) + ) + self.in_channels = out_channels + + return nn.Sequential(*layers) + + def forward(self, x): + x = self.conv1(x) + + outs = {} + for i, layer_name in enumerate(self.layers): + layer = getattr(self, layer_name) + x = layer(x) + if layer_name in self.layer_to_res: + res_block = self.layer_to_res[layer_name] + if res_block in self._out_features: + outs[res_block] = x + + return outs + + def _freeze_stages(self): + if self.frozen_stages >= 0: + for param in self.conv1.parameters(): + param.requires_grad = False + for i in range(1, self.frozen_stages + 1): + layer = getattr(self, f"layer{i}") + layer.eval() + for param in layer.parameters(): + param.requires_grad = False diff --git a/focoos/nn/backbone/resnet.py b/focoos/nn/backbone/resnet.py new file mode 100644 index 00000000..7657be7e --- /dev/null +++ b/focoos/nn/backbone/resnet.py @@ -0,0 +1,266 @@ +from collections import OrderedDict +from dataclasses import dataclass + +import torch +import torch.nn as nn +import torch.nn.functional as F + +from focoos.nn.layers.base import _get_activation_fn as get_activation +from focoos.nn.layers.conv import ConvNormLayer +from focoos.nn.layers.norm import FrozenBatchNorm2d +from focoos.utils.logger import get_logger + +from .base import BackboneConfig, BaseBackbone + +logger = get_logger("Backbone") + +resnet_cfg = { + 18: [2, 2, 2, 2], + 34: [3, 4, 6, 3], + 50: [3, 4, 6, 3], + 101: [3, 4, 23, 3], + # 152: [3, 8, 36, 3], +} + +donwload_url = { + 18: "https://public.focoos.ai/pretrained_models/backbones/resnet18.pth", + 34: "https://public.focoos.ai/pretrained_models/backbones/resnet34.pt", + 50: "https://public.focoos.ai/pretrained_models/backbones/resnet50.pt", + 101: "https://public.focoos.ai/pretrained_models/backbones/resnet101.pt", +} + + +class BasicBlock(nn.Module): + expansion = 1 + + def __init__(self, ch_in, ch_out, stride, shortcut, act="relu", variant="b"): + super().__init__() + + self.shortcut = shortcut + + if not shortcut: + if variant == "d" and stride == 2: + self.short = nn.Sequential( + OrderedDict( + [ + ("pool", nn.AvgPool2d(2, 2, 0, ceil_mode=True)), + ("conv", ConvNormLayer(ch_in, ch_out, 1, 1)), + ] + ) + ) + else: + self.short = ConvNormLayer(ch_in, ch_out, 1, stride) + + self.branch2a = ConvNormLayer(ch_in, ch_out, 3, stride, act=act) + self.branch2b = ConvNormLayer(ch_out, ch_out, 3, 1, act=None) + self.act = nn.Identity() if act is None else get_activation(act) + + def forward(self, x): + out = self.branch2a(x) + out = self.branch2b(out) + if self.shortcut: + short = x + else: + short = self.short(x) + + out = out + short + out = self.act(out) + + return out + + +class BottleNeck(nn.Module): + expansion = 4 + + def __init__(self, ch_in, ch_out, stride, shortcut, act="relu", variant="b"): + super().__init__() + + if variant == "a": + stride1, stride2 = stride, 1 + else: + stride1, stride2 = 1, stride + + width = ch_out + + self.branch2a = ConvNormLayer(ch_in, width, 1, stride1, act=act) + self.branch2b = ConvNormLayer(width, width, 3, stride2, act=act) + self.branch2c = ConvNormLayer(width, ch_out * self.expansion, 1, 1) + + self.shortcut = shortcut + if not shortcut: + if variant == "d" and stride == 2: + self.short = nn.Sequential( + OrderedDict( + [ + ("pool", nn.AvgPool2d(2, 2, 0, ceil_mode=True)), + ( + "conv", + ConvNormLayer(ch_in, ch_out * self.expansion, 1, 1), + ), + ] + ) + ) + else: + self.short = ConvNormLayer(ch_in, ch_out * self.expansion, 1, stride) + + self.act = nn.Identity() if act is None else get_activation(act) + + def forward(self, x): + out = self.branch2a(x) + out = self.branch2b(out) + out = self.branch2c(out) + + if self.shortcut: + short = x + else: + short = self.short(x) + + out = out + short + out = self.act(out) + + return out + + +class Blocks(nn.Module): + def __init__(self, block, ch_in, ch_out, count, stage_num, act="relu", variant="b"): + super().__init__() + + self.blocks = nn.ModuleList() + for i in range(count): + self.blocks.append( + block( + ch_in, + ch_out, + stride=2 if i == 0 and stage_num != 2 else 1, + shortcut=False if i == 0 else True, + variant=variant, + act=act, + ) + ) + + if i == 0: + ch_in = ch_out * block.expansion + + def forward(self, x): + out = x + for block in self.blocks: + out = block(out) + return out + + +@dataclass +class ResnetConfig(BackboneConfig): + in_chans: int = 3 + depth: int = 50 + variant: str = "d" + freeze_at: int = -1 + num_stages: int = 4 + freeze_norm: bool = True + model_type: str = "resnet" + act: str = "relu" + pretrained: bool = False + + +class ResNet(BaseBackbone): + def __init__( + self, + config: ResnetConfig, + ): + super().__init__(config) + + depth = config.depth + variant = config.variant + num_stages = config.num_stages + act = config.act + freeze_at = config.freeze_at + freeze_norm = config.freeze_norm + use_pretrained = config.use_pretrained + backbone_url = config.backbone_url if config.backbone_url else donwload_url[depth] + + block_nums = resnet_cfg[depth] + ch_in = 64 + if variant in ["c", "d"]: + conv_def = [ + [config.in_chans, ch_in // 2, 3, 2, "conv1_1"], + [ch_in // 2, ch_in // 2, 3, 1, "conv1_2"], + [ch_in // 2, ch_in, 3, 1, "conv1_3"], + ] + else: + conv_def = [[config.in_chans, ch_in, 7, 2, "conv1_1"]] + + self.conv1 = nn.Sequential( + OrderedDict([(_name, ConvNormLayer(c_in, c_out, k, s, act=act)) for c_in, c_out, k, s, _name in conv_def]) + ) + + ch_out_list = [64, 128, 256, 512] + block = BottleNeck if depth >= 50 else BasicBlock + + _out_channels = [block.expansion * v for v in ch_out_list] + _out_strides = [4, 8, 16, 32] + + self.res_layers = nn.ModuleList() + for i in range(num_stages): + stage_num = i + 2 + self.res_layers.append( + Blocks( + block, + ch_in, + ch_out_list[i], + block_nums[i], + stage_num, + act=act, + variant=variant, + ) + ) + ch_in = _out_channels[i] + + self.return_idx = [0, 1, 2, 3] + self.out_channels = [_out_channels[_i] for _i in self.return_idx] + self.out_strides = [_out_strides[_i] for _i in self.return_idx] + + if freeze_at >= 0: + self._freeze_parameters(self.conv1) + for i in range(min(freeze_at, num_stages)): + self._freeze_parameters(self.res_layers[i]) + + if freeze_norm: + self._freeze_norm(self) + + if use_pretrained: + state = torch.hub.load_state_dict_from_url(backbone_url) + self.load_state_dict(state) + logger.info(f"Load ResNet{depth} state_dict") + + self._out_features = ["res2", "res3", "res4", "res5"] + self._out_feature_strides = {self._out_features[j]: self.out_strides[j] for j in range(4)} + self._out_feature_channels = {self._out_features[j]: self.out_channels[j] for j in range(4)} + + def _freeze_parameters(self, m: nn.Module): + for p in m.parameters(): + p.requires_grad = False + + def _freeze_norm(self, m: nn.Module): + if isinstance(m, nn.BatchNorm2d): + m = FrozenBatchNorm2d(m.num_features) + else: + for name, child in m.named_children(): + _child = self._freeze_norm(child) + if _child is not child: + setattr(m, name, _child) + return m + + def forward(self, x): + conv1 = self.conv1(x) + x = F.max_pool2d(conv1, kernel_size=3, stride=2, padding=1) + outs = [] + for idx, stage in enumerate(self.res_layers): + x = stage(x) + if idx in self.return_idx: + outs.append(x) + + return { + "res2": outs[0], + "res3": outs[1], + "res4": outs[2], + "res5": outs[3], + } diff --git a/focoos/nn/backbone/stdc.py b/focoos/nn/backbone/stdc.py new file mode 100644 index 00000000..ba1cefbb --- /dev/null +++ b/focoos/nn/backbone/stdc.py @@ -0,0 +1,299 @@ +import math +from dataclasses import dataclass, field +from typing import List, Literal, Optional + +import torch +import torch.nn as nn +from torch.nn import init + +from focoos.utils.logger import get_logger + +from .base import BackboneConfig, BaseBackbone + +logger = get_logger("Backbone") + + +class ConvX(nn.Module): + def __init__(self, in_planes, out_planes, kernel=3, stride=1): + super().__init__() + self.conv = nn.Conv2d( + in_planes, + out_planes, + kernel_size=kernel, + stride=stride, + padding=kernel // 2, + bias=False, + ) + self.bn = nn.BatchNorm2d(out_planes) + self.relu = nn.ReLU(inplace=True) + + def forward(self, x): + out = self.relu(self.bn(self.conv(x))) + return out + + +class AddBottleneck(nn.Module): + def __init__(self, in_planes, out_planes, block_num=3, stride=1): + super().__init__() + assert block_num > 1, print("block number should be larger than 1.") + self.conv_list = nn.ModuleList() + self.stride = stride + if stride == 2: + self.avd_layer = nn.Sequential( + nn.Conv2d( + out_planes // 2, + out_planes // 2, + kernel_size=3, + stride=2, + padding=1, + groups=out_planes // 2, + bias=False, + ), + nn.BatchNorm2d(out_planes // 2), + ) + self.skip = nn.Sequential( + nn.Conv2d( + in_planes, + in_planes, + kernel_size=3, + stride=2, + padding=1, + groups=in_planes, + bias=False, + ), + nn.BatchNorm2d(in_planes), + nn.Conv2d(in_planes, out_planes, kernel_size=1, bias=False), + nn.BatchNorm2d(out_planes), + ) + stride = 1 + + for idx in range(block_num): + if idx == 0: + self.conv_list.append(ConvX(in_planes, out_planes // 2, kernel=1)) + elif idx == 1 and block_num == 2: + self.conv_list.append(ConvX(out_planes // 2, out_planes // 2, stride=stride)) + elif idx == 1 and block_num > 2: + self.conv_list.append(ConvX(out_planes // 2, out_planes // 4, stride=stride)) + elif idx < block_num - 1: + self.conv_list.append( + ConvX( + out_planes // int(math.pow(2, idx)), + out_planes // int(math.pow(2, idx + 1)), + ) + ) + else: + self.conv_list.append( + ConvX( + out_planes // int(math.pow(2, idx)), + out_planes // int(math.pow(2, idx)), + ) + ) + + def forward(self, x): + out_list = [] + out = x + + for idx, conv in enumerate(self.conv_list): + if idx == 0 and self.stride == 2: + out = self.avd_layer(conv(out)) + else: + out = conv(out) + out_list.append(out) + + if self.stride == 2: + x = self.skip(x) + + return torch.cat(out_list, dim=1) + x + + +class CatBottleneck(nn.Module): + def __init__(self, in_planes, out_planes, block_num=3, stride=1): + super().__init__() + assert block_num > 1, print("block number should be larger than 1.") + self.conv_list = nn.ModuleList() + self.stride = stride + if stride == 2: + self.avd_layer = nn.Sequential( + nn.Conv2d( + out_planes // 2, + out_planes // 2, + kernel_size=3, + stride=2, + padding=1, + groups=out_planes // 2, + bias=False, + ), + nn.BatchNorm2d(out_planes // 2), + ) + self.skip = nn.AvgPool2d(kernel_size=3, stride=2, padding=1) + stride = 1 + + for idx in range(block_num): + if idx == 0: + self.conv_list.append(ConvX(in_planes, out_planes // 2, kernel=1)) + elif idx == 1 and block_num == 2: + self.conv_list.append(ConvX(out_planes // 2, out_planes // 2, stride=stride)) + elif idx == 1 and block_num > 2: + self.conv_list.append(ConvX(out_planes // 2, out_planes // 4, stride=stride)) + elif idx < block_num - 1: + self.conv_list.append( + ConvX( + out_planes // int(math.pow(2, idx)), + out_planes // int(math.pow(2, idx + 1)), + ) + ) + else: + self.conv_list.append( + ConvX( + out_planes // int(math.pow(2, idx)), + out_planes // int(math.pow(2, idx)), + ) + ) + + def forward(self, x): + out_list = [] + out1 = self.conv_list[0](x) + + for idx, conv in enumerate(self.conv_list[1:]): # type: ignore + if idx == 0: + if self.stride == 2: + out = conv(self.avd_layer(out1)) + else: + out = conv(out1) + else: + out = conv(out) + out_list.append(out) + + if self.stride == 2: + out1 = self.skip(out1) + out_list.insert(0, out1) + + out = torch.cat(out_list, dim=1) + return out + + +@dataclass +class STDCConfig(BackboneConfig): + in_chans: int = 3 + base: int = 64 # from json: "base": 64 + layers: List[int] = field(default_factory=lambda: [4, 5, 3]) # from json: "layers": [4, 5, 3] + out_features: List[str] = field(default_factory=lambda: ["res2", "res3", "res4", "res5"]) # from json + model_type: str = "stdc" + block_num: int = 4 + block_type: str = "cat" + backbone_url: Optional[str] = None + size: Optional[Literal["small", "large"]] = None + use_conv_last: bool = False + + +class STDC(BaseBackbone): + def __init__(self, config: STDCConfig): + super().__init__(config) + + if config.size == "small": + config.backbone_url = "https://public.focoos.ai/pretrained_models/backbones/stdc_small.pth" + layers = [2, 2, 2] + base = 64 + block_num = 4 + block_type = "cat" + elif config.size == "large": + config.backbone_url = "https://public.focoos.ai/pretrained_models/backbones/stdc_large.pth" + layers = [4, 5, 3] + base = 64 + block_num = 4 + block_type = "cat" + else: + base = config.base + layers = config.layers + block_num = config.block_num + block_type = config.block_type + + if block_type == "cat": + block = CatBottleneck + elif block_type == "add": + block = AddBottleneck + + if config.layers != [2, 2, 2] and config.layers != [4, 5, 3]: + raise ValueError(f"Invalid layers: {config.layers}. The layers should be [2, 2, 2] or [4, 5, 3].") + + self.in_chans = config.in_chans + self.features = self._make_layers(base, layers, block_num, block) + + if layers == [2, 2, 2]: + self.out_ids = 1, 3, 5, 7 + + elif layers == [4, 5, 3]: + self.out_ids = 1, 5, 10, 13 + + if config.use_pretrained and config.backbone_url: + state = torch.hub.load_state_dict_from_url(config.backbone_url) + self.load_state_dict(state) + logger.info("Load STDC state_dict") + + self._out_features = config.out_features + + self._out_feature_strides = { + "res2": 4, + "res3": 8, + "res4": 16, + "res5": 32, + } + self._out_feature_channels = { + "res2": base, + "res3": base * 4, + "res4": base * 8, + "res5": base * 16, + } + + def init_params(self): + for m in self.modules(): + if isinstance(m, nn.Conv2d): + init.kaiming_normal_(m.weight, mode="fan_out") + if m.bias is not None: + init.constant_(m.bias, 0) + elif isinstance(m, nn.BatchNorm2d): + init.constant_(m.weight, 1) + init.constant_(m.bias, 0) + elif isinstance(m, nn.Linear): + init.normal_(m.weight, std=0.001) + if m.bias is not None: + init.constant_(m.bias, 0) + + def _make_layers(self, base, layers, block_num, block): + features = [] + features += [ConvX(self.in_chans, base // 2, 3, 2)] + features += [ConvX(base // 2, base, 3, 2)] + + for i, layer in enumerate(layers): + for j in range(layer): + if i == 0 and j == 0: + features.append(block(base, base * 4, block_num, 2)) + elif j == 0: + features.append( + block( + base * int(math.pow(2, i + 1)), + base * int(math.pow(2, i + 2)), + block_num, + 2, + ) + ) + else: + features.append( + block( + base * int(math.pow(2, i + 2)), + base * int(math.pow(2, i + 2)), + block_num, + 1, + ) + ) + + return nn.Sequential(*features) + + def forward(self, x): + outs = [] + for i, layer in enumerate(self.features): + x = layer(x) + if i in self.out_ids: + outs.append(x) + outs = {f"res{i + 2}": outs[i] for i in range(len(outs))} + return outs diff --git a/focoos/nn/backbone/swin.py b/focoos/nn/backbone/swin.py new file mode 100644 index 00000000..156195b7 --- /dev/null +++ b/focoos/nn/backbone/swin.py @@ -0,0 +1,781 @@ +from dataclasses import dataclass +from typing import Literal, Optional, Tuple + +import numpy as np +import torch +import torch.nn as nn +import torch.nn.functional as F +import torch.utils.checkpoint as checkpoint +from torch.nn.init import trunc_normal_ + +from focoos.nn.layers.misc import DropPath, to_2tuple +from focoos.utils.logger import get_logger + +from .base import BackboneConfig, BaseBackbone, ShapeSpec + +logger = get_logger("Backbone") + + +def window_partition(x, window_size): + """ + Args: + x: (B, H, W, C) + window_size (int): window size + Returns: + windows: (num_windows*B, window_size, window_size, C) + """ + B, H, W, C = x.shape + x = x.view(B, H // window_size, window_size, W // window_size, window_size, C) + windows = x.permute(0, 1, 3, 2, 4, 5).contiguous().view(-1, window_size, window_size, C) + return windows + + +def window_reverse(windows, window_size, H, W): + """ + Args: + windows: (num_windows*B, window_size, window_size, C) + window_size (int): Window size + H (int): Height of image + W (int): Width of image + Returns: + x: (B, H, W, C) + """ + B = int(windows.shape[0] / (H * W / window_size / window_size)) + x = windows.view(B, H // window_size, W // window_size, window_size, window_size, -1) + x = x.permute(0, 1, 3, 2, 4, 5).contiguous().view(B, H, W, -1) + return x + + +class Mlp(nn.Module): + # todo: substitute with nn.layer.base.MLP + """Multilayer perceptron.""" + + def __init__( + self, + in_features, + hidden_features=None, + out_features=None, + act_layer=nn.GELU, + drop=0.0, + ): + super().__init__() + out_features = out_features or in_features + hidden_features = hidden_features or in_features + self.fc1 = nn.Linear(in_features, hidden_features) + self.act = act_layer() + self.fc2 = nn.Linear(hidden_features, out_features) + self.drop = nn.Dropout(drop) + + def forward(self, x): + x = self.fc1(x) + x = self.act(x) + x = self.drop(x) + x = self.fc2(x) + x = self.drop(x) + return x + + +class WindowAttention(nn.Module): + """Window based multi-head self attention (W-MSA) module with relative position bias. + It supports both of shifted and non-shifted window. + Args: + dim (int): Number of input channels. + window_size (tuple[int]): The height and width of the window. + num_heads (int): Number of attention heads. + qkv_bias (bool, optional): If True, add a learnable bias to query, key, value. Default: True + qk_scale (float | None, optional): Override default qk scale of head_dim ** -0.5 if set + attn_drop (float, optional): Dropout ratio of attention weight. Default: 0.0 + proj_drop (float, optional): Dropout ratio of output. Default: 0.0 + """ + + def __init__( + self, + dim, + window_size, + num_heads, + qkv_bias=True, + qk_scale=None, + attn_drop=0.0, + proj_drop=0.0, + ): + super().__init__() + self.dim = dim + self.window_size = window_size # Wh, Ww + self.num_heads = num_heads + head_dim = dim // num_heads + self.scale = qk_scale or head_dim**-0.5 + + # define a parameter table of relative position bias + self.relative_position_bias_table = nn.Parameter( + torch.zeros((2 * window_size[0] - 1) * (2 * window_size[1] - 1), num_heads) + ) # 2*Wh-1 * 2*Ww-1, nH + + # get pair-wise relative position index for each token inside the window + coords_h = torch.arange(self.window_size[0]) + coords_w = torch.arange(self.window_size[1]) + coords = torch.stack(torch.meshgrid([coords_h, coords_w])) # 2, Wh, Ww + coords_flatten = torch.flatten(coords, 1) # 2, Wh*Ww + relative_coords = coords_flatten[:, :, None] - coords_flatten[:, None, :] # 2, Wh*Ww, Wh*Ww + relative_coords = relative_coords.permute(1, 2, 0).contiguous() # Wh*Ww, Wh*Ww, 2 + relative_coords[:, :, 0] += self.window_size[0] - 1 # shift to start from 0 + relative_coords[:, :, 1] += self.window_size[1] - 1 + relative_coords[:, :, 0] *= 2 * self.window_size[1] - 1 + relative_position_index = relative_coords.sum(-1) # Wh*Ww, Wh*Ww + self.relative_position_index = nn.Parameter(relative_position_index, requires_grad=False) + + self.qkv = nn.Linear(dim, dim * 3, bias=qkv_bias) + self.attn_drop = nn.Dropout(attn_drop) + self.proj = nn.Linear(dim, dim) + self.proj_drop = nn.Dropout(proj_drop) + + trunc_normal_(self.relative_position_bias_table, std=0.02) + self.softmax = nn.Softmax(dim=-1) + + def forward(self, x, mask=None): + """Forward function. + Args: + x: input features with shape of (num_windows*B, N, C) + mask: (0/-inf) mask with shape of (num_windows, Wh*Ww, Wh*Ww) or None + """ + B_, N, C = x.shape + qkv = self.qkv(x).reshape(B_, N, 3, self.num_heads, C // self.num_heads).permute(2, 0, 3, 1, 4) + q, k, v = ( + qkv[0], + qkv[1], + qkv[2], + ) # make torchscript happy (cannot use tensor as tuple) + + q = q * self.scale + attn = q @ k.transpose(-2, -1) + + relative_position_bias = self.relative_position_bias_table[self.relative_position_index.view(-1)].view( + self.window_size[0] * self.window_size[1], + self.window_size[0] * self.window_size[1], + -1, + ) # Wh*Ww,Wh*Ww,nH + relative_position_bias = relative_position_bias.permute(2, 0, 1).contiguous() # nH, Wh*Ww, Wh*Ww + attn = attn + relative_position_bias.unsqueeze(0) + + if mask is not None: + nW = mask.shape[0] + attn = attn.view(B_ // nW, nW, self.num_heads, N, N) + mask.unsqueeze(1).unsqueeze(0) + attn = attn.view(-1, self.num_heads, N, N) + attn = self.softmax(attn) + else: + attn = self.softmax(attn) + + attn = self.attn_drop(attn) + + x = (attn @ v).transpose(1, 2).reshape(B_, N, C) + x = self.proj(x) + x = self.proj_drop(x) + return x + + +class SwinTransformerBlock(nn.Module): + """Swin Transformer Block. + Args: + dim (int): Number of input channels. + num_heads (int): Number of attention heads. + window_size (int): Window size. + shift_size (int): Shift size for SW-MSA. + mlp_ratio (float): Ratio of mlp hidden dim to embedding dim. + qkv_bias (bool, optional): If True, add a learnable bias to query, key, value. Default: True + qk_scale (float | None, optional): Override default qk scale of head_dim ** -0.5 if set. + drop (float, optional): Dropout rate. Default: 0.0 + attn_drop (float, optional): Attention dropout rate. Default: 0.0 + drop_path (float, optional): Stochastic depth rate. Default: 0.0 + act_layer (nn.Module, optional): Activation layer. Default: nn.GELU + norm_layer (nn.Module, optional): Normalization layer. Default: nn.LayerNorm + """ + + def __init__( + self, + dim, + num_heads, + window_size=7, + shift_size=0, + mlp_ratio=4.0, + qkv_bias=True, + qk_scale=None, + drop=0.0, + attn_drop=0.0, + drop_path=0.0, + act_layer=nn.GELU, + norm_layer=nn.LayerNorm, + ): + super().__init__() + self.dim = dim + self.num_heads = num_heads + self.window_size = window_size + self.shift_size = shift_size + self.mlp_ratio = mlp_ratio + assert 0 <= self.shift_size < self.window_size, "shift_size must in 0-window_size" + + self.norm1 = norm_layer(dim) + self.attn = WindowAttention( + dim, + window_size=to_2tuple(self.window_size), + num_heads=num_heads, + qkv_bias=qkv_bias, + qk_scale=qk_scale, + attn_drop=attn_drop, + proj_drop=drop, + ) + + self.drop_path = DropPath(drop_path) if drop_path > 0.0 else nn.Identity() + self.norm2 = norm_layer(dim) + mlp_hidden_dim = int(dim * mlp_ratio) + self.mlp = Mlp( + in_features=dim, + hidden_features=mlp_hidden_dim, + act_layer=act_layer, + drop=drop, + ) + + self.H = None + self.W = None + + def forward(self, x, mask_matrix): + """Forward function. + Args: + x: Input feature, tensor size (B, H*W, C). + H, W: Spatial resolution of the input feature. + mask_matrix: Attention mask for cyclic shift. + """ + B, L, C = x.shape + assert self.H is not None and self.W is not None, "H and W must be set before forward" + H, W = self.H, self.W + assert L == H * W, "input feature has wrong size" + + shortcut = x + x = self.norm1(x) + x = x.view(B, H, W, C) + + # pad feature maps to multiples of window size + pad_l = pad_t = 0 + pad_r = (self.window_size - W % self.window_size) % self.window_size + pad_b = (self.window_size - H % self.window_size) % self.window_size + x = F.pad(x, (0, 0, pad_l, pad_r, pad_t, pad_b)) + _, Hp, Wp, _ = x.shape + + # cyclic shift + if self.shift_size > 0: + shifted_x = torch.roll(x, shifts=(-self.shift_size, -self.shift_size), dims=(1, 2)) + attn_mask = mask_matrix + else: + shifted_x = x + attn_mask = None + + # partition windows + x_windows = window_partition(shifted_x, self.window_size) # nW*B, window_size, window_size, C + x_windows = x_windows.view(-1, self.window_size * self.window_size, C) # nW*B, window_size*window_size, C + + # W-MSA/SW-MSA + attn_windows = self.attn(x_windows, mask=attn_mask) # nW*B, window_size*window_size, C + + # merge windows + attn_windows = attn_windows.view(-1, self.window_size, self.window_size, C) + shifted_x = window_reverse(attn_windows, self.window_size, Hp, Wp) # B H' W' C + + # reverse cyclic shift + if self.shift_size > 0: + x = torch.roll(shifted_x, shifts=(self.shift_size, self.shift_size), dims=(1, 2)) + else: + x = shifted_x + + if pad_r > 0 or pad_b > 0: + x = x[:, :H, :W, :].contiguous() + + x = x.view(B, H * W, C) + + # FFN + x = shortcut + self.drop_path(x) + x = x + self.drop_path(self.mlp(self.norm2(x))) + + return x + + +class PatchMerging(nn.Module): + """Patch Merging Layer + Args: + dim (int): Number of input channels. + norm_layer (nn.Module, optional): Normalization layer. Default: nn.LayerNorm + """ + + def __init__(self, dim, norm_layer=nn.LayerNorm): + super().__init__() + self.dim = dim + self.reduction = nn.Linear(4 * dim, 2 * dim, bias=False) + self.norm = norm_layer(4 * dim) + + def forward(self, x, H, W): + """Forward function. + Args: + x: Input feature, tensor size (B, H*W, C). + H, W: Spatial resolution of the input feature. + """ + B, L, C = x.shape + assert L == H * W, "input feature has wrong size" + + x = x.view(B, H, W, C) + + # padding + pad_input = (H % 2 == 1) or (W % 2 == 1) + if pad_input: + x = F.pad(x, (0, 0, 0, W % 2, 0, H % 2)) + + x0 = x[:, 0::2, 0::2, :] # B H/2 W/2 C + x1 = x[:, 1::2, 0::2, :] # B H/2 W/2 C + x2 = x[:, 0::2, 1::2, :] # B H/2 W/2 C + x3 = x[:, 1::2, 1::2, :] # B H/2 W/2 C + x = torch.cat([x0, x1, x2, x3], -1) # B H/2 W/2 4*C + x = x.view(B, -1, 4 * C) # B H/2*W/2 4*C + + x = self.norm(x) + x = self.reduction(x) + + return x + + +class BasicLayer(nn.Module): + """A basic Swin Transformer layer for one stage. + Args: + dim (int): Number of feature channels + depth (int): Depths of this stage. + num_heads (int): Number of attention head. + window_size (int): Local window size. Default: 7. + mlp_ratio (float): Ratio of mlp hidden dim to embedding dim. Default: 4. + qkv_bias (bool, optional): If True, add a learnable bias to query, key, value. Default: True + qk_scale (float | None, optional): Override default qk scale of head_dim ** -0.5 if set. + drop (float, optional): Dropout rate. Default: 0.0 + attn_drop (float, optional): Attention dropout rate. Default: 0.0 + drop_path (float | tuple[float], optional): Stochastic depth rate. Default: 0.0 + norm_layer (nn.Module, optional): Normalization layer. Default: nn.LayerNorm + downsample (nn.Module | None, optional): Downsample layer at the end of the layer. Default: None + use_checkpoint (bool): Whether to use checkpointing to save memory. Default: False. + """ + + def __init__( + self, + dim, + depth, + num_heads, + window_size=7, + mlp_ratio=4.0, + qkv_bias=True, + qk_scale=None, + drop=0.0, + attn_drop=0.0, + drop_path=0.0, + norm_layer=nn.LayerNorm, + downsample=None, + use_checkpoint=False, + ): + super().__init__() + self.window_size = window_size + self.shift_size = window_size // 2 + self.depth = depth + self.use_checkpoint = use_checkpoint + + # build blocks + self.blocks = nn.ModuleList( + [ + SwinTransformerBlock( + dim=dim, + num_heads=num_heads, + window_size=window_size, + shift_size=0 if (i % 2 == 0) else window_size // 2, + mlp_ratio=mlp_ratio, + qkv_bias=qkv_bias, + qk_scale=qk_scale, + drop=drop, + attn_drop=attn_drop, + drop_path=(drop_path[i] if isinstance(drop_path, list) else drop_path), + norm_layer=norm_layer, + ) + for i in range(depth) + ] + ) + + # patch merging layer + if downsample is not None: + self.downsample = downsample(dim=dim, norm_layer=norm_layer) + else: + self.downsample = None + + def forward(self, x, H, W): + """Forward function. + Args: + x: Input feature, tensor size (B, H*W, C). + H, W: Spatial resolution of the input feature. + """ + + # calculate attention mask for SW-MSA + Hp = int(np.ceil(H / self.window_size)) * self.window_size + Wp = int(np.ceil(W / self.window_size)) * self.window_size + img_mask = torch.zeros((1, Hp, Wp, 1), device=x.device) # 1 Hp Wp 1 + h_slices = ( + slice(0, -self.window_size), + slice(-self.window_size, -self.shift_size), + slice(-self.shift_size, None), + ) + w_slices = ( + slice(0, -self.window_size), + slice(-self.window_size, -self.shift_size), + slice(-self.shift_size, None), + ) + cnt = 0 + for h in h_slices: + for w in w_slices: + img_mask[:, h, w, :] = cnt + cnt += 1 + + mask_windows = window_partition(img_mask, self.window_size) # nW, window_size, window_size, 1 + mask_windows = mask_windows.view(-1, self.window_size * self.window_size) + attn_mask = mask_windows.unsqueeze(1) - mask_windows.unsqueeze(2) + attn_mask = attn_mask.masked_fill(attn_mask != 0, -100.0).masked_fill(attn_mask == 0, 0.0) + + for blk in self.blocks: + blk.H, blk.W = H, W + if self.use_checkpoint: + x = checkpoint.checkpoint(blk, x, attn_mask) + else: + x = blk(x, attn_mask) + if self.downsample is not None: + x_down = self.downsample(x, H, W) + Wh, Ww = (H + 1) // 2, (W + 1) // 2 + return x, H, W, x_down, Wh, Ww + else: + return x, H, W, x, H, W + + +class PatchEmbed(nn.Module): + """Image to Patch Embedding + Args: + patch_size (int): Patch token size. Default: 4. + in_chans (int): Number of input image channels. Default: 3. + embed_dim (int): Number of linear projection output channels. Default: 96. + norm_layer (nn.Module, optional): Normalization layer. Default: None + """ + + def __init__(self, patch_size=4, in_chans=3, embed_dim=96, norm_layer=None): + super().__init__() + patch_size = to_2tuple(patch_size) + self.patch_size = patch_size + + self.in_chans = in_chans + self.embed_dim = embed_dim + + self.proj = nn.Conv2d(in_chans, embed_dim, kernel_size=patch_size, stride=patch_size) # type: ignore + if norm_layer is not None: + self.norm = norm_layer(embed_dim) + else: + self.norm = None + + def forward(self, x): + """Forward function.""" + # padding + _, _, H, W = x.size() + if W % self.patch_size[1] != 0: + x = F.pad(x, (0, self.patch_size[1] - W % self.patch_size[1])) + if H % self.patch_size[0] != 0: + x = F.pad(x, (0, 0, 0, self.patch_size[0] - H % self.patch_size[0])) + + x = self.proj(x) # B C Wh Ww + if self.norm is not None: + Wh, Ww = x.size(2), x.size(3) + x = x.flatten(2).transpose(1, 2) + x = self.norm(x) + x = x.transpose(1, 2).view(-1, self.embed_dim, Wh, Ww) + + return x + + +@dataclass +class SwinConfig(BackboneConfig): + """ + Args: + pretrain_img_size (int): Input image size for training the pretrained model, + used in absolute postion embedding. Default 224. + patch_size (int | tuple(int)): Patch size. Default: 4. + in_chans (int): Number of input image channels. Default: 3. + embed_dim (int): Number of linear projection output channels. Default: 96. + depths (tuple[int]): Depths of each Swin Transformer stage. + num_heads (tuple[int]): Number of attention head of each stage. + window_size (int): Window size. Default: 7. + mlp_ratio (float): Ratio of mlp hidden dim to embedding dim. Default: 4. + qkv_bias (bool): If True, add a learnable bias to query, key, value. Default: True + qk_scale (float): Override default qk scale of head_dim ** -0.5 if set. + drop_rate (float): Dropout rate. + attn_drop_rate (float): Attention dropout rate. Default: 0. + drop_path_rate (float): Stochastic depth rate. Default: 0.2. + norm_layer (nn.Module): Normalization layer. Default: nn.LayerNorm. + ape (bool): If True, add absolute position embedding to the patch embedding. Default: False. + patch_norm (bool): If True, add normalization after patch embedding. Default: True. + out_indices (Sequence[int]): Output from which stages. + frozen_stages (int): Stages to be frozen (stop grad and set eval mode). + -1 means not freezing any parameters. + use_checkpoint (bool): Whether to use checkpointing to save memory. Default: False. + """ + + model_size: Optional[Literal["tiny", "small", "base", "large"]] = None + model_type: str = "swin" + pretrain_img_size: int = 224 + patch_size: int = 4 + in_chans: int = 3 + embed_dim: int = 96 + depths: Tuple[int, ...] = (2, 2, 6, 2) + num_heads: Tuple[int, ...] = (3, 6, 12, 24) + window_size: int = 7 + mlp_ratio: float = 4.0 + qkv_bias: bool = True + qk_scale: Optional[float] = None + drop_rate: float = 0.0 + attn_drop_rate: float = 0.0 + drop_path_rate: float = 0.2 + ape: bool = False + patch_norm: bool = True + out_indices: Tuple[int, ...] = (0, 1, 2, 3) + frozen_stages: int = -1 + use_checkpoint: bool = False + + +SWIN_CONFIGS = { + "tiny": { + "embed_dims": 96, + "depths": [2, 2, 6, 2], + "pretr_image_size": 224, + "heads": [3, 6, 12, 24], + "w_size": 7, + "url": "https://public.focoos.ai/pretrained_models/backbones/swin_tiny.pth", + }, + "small": { + "embed_dims": 96, + "depths": [2, 2, 18, 2], + "pretr_image_size": 224, + "heads": [3, 6, 12, 24], + "w_size": 7, + "url": "https://public.focoos.ai/pretrained_models/backbones/swin_small.pth", + }, + "base": { + "embed_dims": 128, + "depths": [2, 2, 18, 2], + "pretr_image_size": 384, + "heads": [4, 8, 16, 32], + "w_size": 12, + "url": "https://public.focoos.ai/pretrained_models/backbones/swin_base.pth", + }, + "large": { + "embed_dims": 192, + "depths": [2, 2, 18, 2], + "pretr_image_size": 384, + "heads": [6, 12, 24, 48], + "w_size": 12, + "url": "https://public.focoos.ai/pretrained_models/backbones/swin_large.pth", + }, +} + + +class Swin(BaseBackbone): + """Swin Transformer backbone. + A PyTorch impl of : `Swin Transformer: Hierarchical Vision Transformer using Shifted Windows` - + https://arxiv.org/pdf/2103.14030 + """ + + def __init__( + self, + config: SwinConfig, + ): + super().__init__(config) + + if config.model_size is not None: + self.pretrain_img_size = SWIN_CONFIGS[config.model_size]["pretr_image_size"] + self.depths = SWIN_CONFIGS[config.model_size]["depths"] + self.num_heads = SWIN_CONFIGS[config.model_size]["heads"] + self.embed_dim = SWIN_CONFIGS[config.model_size]["embed_dims"] + self.window_size = SWIN_CONFIGS[config.model_size]["w_size"] + backbone_url = SWIN_CONFIGS[config.model_size]["url"] + else: + self.pretrain_img_size = config.pretrain_img_size + self.num_layers = len(config.depths) + self.embed_dim = config.embed_dim + self.num_heads = config.num_heads + self.window_size = config.window_size + self.depths = config.depths + backbone_url = config.backbone_url + + self.ape = config.ape + self.patch_norm = config.patch_norm + self.out_indices = config.out_indices + self.frozen_stages = config.frozen_stages + self.use_checkpoint = config.use_checkpoint + self.patch_size = config.patch_size + self.in_chans = config.in_chans + self.drop_rate = config.drop_rate + self.attn_drop_rate = config.attn_drop_rate + self.drop_path_rate = config.drop_path_rate + self.num_layers = len(self.depths) + self.mlp_ratio = config.mlp_ratio + self.qkv_bias = config.qkv_bias + self.qk_scale = config.qk_scale + + # split image into non-overlapping patches + self.patch_embed = PatchEmbed( + patch_size=self.patch_size, + in_chans=self.in_chans, + embed_dim=self.embed_dim, + norm_layer=nn.LayerNorm if self.patch_norm else None, + ) + + # absolute position embedding + if self.ape: + pretrain_img_size = to_2tuple(self.pretrain_img_size) + patch_size = to_2tuple(self.patch_size) + patches_resolution = [ + pretrain_img_size[0] // patch_size[0], # type: ignore + pretrain_img_size[1] // patch_size[1], # type: ignore + ] + + self.absolute_pos_embed = nn.Parameter( + torch.zeros(1, self.embed_dim, patches_resolution[0], patches_resolution[1]) + ) + trunc_normal_(self.absolute_pos_embed, std=0.02) + + self.pos_drop = nn.Dropout(p=self.drop_rate) + + # stochastic depth + dpr = [ + x.item() for x in torch.linspace(0, self.drop_path_rate, sum(self.depths)) + ] # stochastic depth decay rule + + # build layers + self.layers = nn.ModuleList() + for i_layer in range(self.num_layers): + layer = BasicLayer( + dim=int(self.embed_dim * 2**i_layer), + depth=self.depths[i_layer], + num_heads=self.num_heads[i_layer], + window_size=self.window_size, + mlp_ratio=self.mlp_ratio if isinstance(self.mlp_ratio, float) else self.mlp_ratio, + qkv_bias=self.qkv_bias, + qk_scale=self.qk_scale, + drop=self.drop_rate, + attn_drop=self.attn_drop_rate, + drop_path=dpr[sum(self.depths[:i_layer]) : sum(self.depths[: i_layer + 1])], # type: ignore + norm_layer=nn.LayerNorm, + downsample=PatchMerging if (i_layer < self.num_layers - 1) else None, + use_checkpoint=self.use_checkpoint, + ) + self.layers.append(layer) + + num_features = [int(self.embed_dim * 2**i) for i in range(self.num_layers)] + self.num_features = num_features + + # add a norm layer for each output + for i_layer in self.out_indices: + layer = nn.LayerNorm(num_features[i_layer]) + layer_name = f"norm{i_layer}" + self.add_module(layer_name, layer) + + if config.use_pretrained and backbone_url: + state = torch.hub.load_state_dict_from_url(backbone_url) + self.load_state_dict(state, strict=False) + logger.info(f"Loaded pretrained weights from {backbone_url}") + + # Set output features + self._out_features = ["res2", "res3", "res4", "res5"] + self._out_feature_strides = { + "res2": 4, + "res3": 8, + "res4": 16, + "res5": 32, + } + self._out_feature_channels = { + "res2": self.num_features[0], + "res3": self.num_features[1], + "res4": self.num_features[2], + "res5": self.num_features[3], + } + + self._freeze_stages() + + def _freeze_stages(self): + if self.frozen_stages >= 0: + self.patch_embed.eval() + for param in self.patch_embed.parameters(): + param.requires_grad = False + + if self.frozen_stages >= 1 and self.ape: + self.absolute_pos_embed.requires_grad = False + + if self.frozen_stages >= 2: + self.pos_drop.eval() + for i in range(0, self.frozen_stages - 1): + m = self.layers[i] + m.eval() + for param in m.parameters(): + param.requires_grad = False + + def init_weights(self, pretrained=None): + """Initialize the weights in backbone. + Args: + pretrained (str, optional): Path to pre-trained weights. + Defaults to None. + """ + + def _init_weights(m): + if isinstance(m, nn.Linear): + trunc_normal_(m.weight, std=0.02) + if isinstance(m, nn.Linear) and m.bias is not None: + nn.init.constant_(m.bias, 0) + elif isinstance(m, nn.LayerNorm): + nn.init.constant_(m.bias, 0) + nn.init.constant_(m.weight, 1.0) + + def forward(self, x): + """Forward function.""" + assert x.dim() == 4, f"SwinTransformer takes an input of shape (N, C, H, W). Got {x.shape} instead!" + + x = self.patch_embed(x) + + Wh, Ww = x.size(2), x.size(3) + if self.ape: + # interpolate the position embedding to the corresponding size + absolute_pos_embed = F.interpolate(self.absolute_pos_embed, size=(Wh, Ww), mode="bicubic") + x = (x + absolute_pos_embed).flatten(2).transpose(1, 2) # B Wh*Ww C + else: + x = x.flatten(2).transpose(1, 2) + x = self.pos_drop(x) + + outputs = {} + for i in range(self.num_layers): + layer = self.layers[i] + x_out, H, W, x, Wh, Ww = layer(x, Wh, Ww) + + if i in self.out_indices: + norm_layer = getattr(self, f"norm{i}") + x_out = norm_layer(x_out) + + out = x_out.view(-1, H, W, self.num_features[i]).permute(0, 3, 1, 2).contiguous() + outputs[f"res{i + 2}"] = out + + return {name: outputs[name] for name in self._out_features if name in outputs} + + def output_shape(self): + return { + name: ShapeSpec( + channels=self._out_feature_channels[name], + stride=self._out_feature_strides[name], + ) + for name in self._out_features + } + + @property + def size_divisibility(self): + return 32 + + def train(self, mode=True): + """Convert the model into training mode while keep layers freezed.""" + super().train(mode) + self._freeze_stages() diff --git a/focoos/nn/layers/__init__.py b/focoos/nn/layers/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/focoos/nn/layers/aspp.py b/focoos/nn/layers/aspp.py new file mode 100644 index 00000000..4477b817 --- /dev/null +++ b/focoos/nn/layers/aspp.py @@ -0,0 +1,167 @@ +# Copyright (c) Facebook, Inc. and its affiliates. + +from copy import deepcopy + +import torch +from torch import nn +from torch.nn import functional as F + +from .conv import Conv2d, DepthwiseSeparableConv2d +from .norm import get_norm + + +class ASPP(nn.Module): + """ + Atrous Spatial Pyramid Pooling (ASPP). + """ + + def __init__( + self, + in_channels, + out_channels, + dilations, + *, + norm, + activation, + pool_kernel_size=None, + dropout: float = 0.0, + use_depthwise_separable_conv=False, + ): + """ + Args: + in_channels (int): number of input channels for ASPP. + out_channels (int): number of output channels. + dilations (list): a list of 3 dilations in ASPP. + norm (str or callable): normalization for all conv layers. + See :func:`layers.get_norm` for supported format. norm is + applied to all conv layers except the conv following + global average pooling. + activation (callable): activation function. + pool_kernel_size (tuple, list): the average pooling size (kh, kw) + for image pooling layer in ASPP. If set to None, it always + performs global average pooling. If not None, it must be + divisible by the shape of inputs in forward(). It is recommended + to use a fixed input feature size in training, and set this + option to match this size, so that it performs global average + pooling in training, and the size of the pooling window stays + consistent in inference. + dropout (float): apply dropout on the output of ASPP. It is used in + the official DeepLab implementation with a rate of 0.1: + https://github.com/tensorflow/models/blob/21b73d22f3ed05b650e85ac50849408dd36de32e/research/deeplab/model.py#L532 # noqa + use_depthwise_separable_conv (bool): use DepthwiseSeparableConv2d + for 3x3 convs in ASPP, proposed in :paper:`DeepLabV3+`. + """ + super().__init__() + assert len(dilations) == 3, "ASPP expects 3 dilations, got {}".format(len(dilations)) + self.pool_kernel_size = pool_kernel_size + self.dropout = dropout + use_bias = norm == "" + self.convs = nn.ModuleList() + # conv 1x1 + self.convs.append( + Conv2d( + in_channels, + out_channels, + kernel_size=1, + bias=use_bias, + norm=get_norm(norm, out_channels), + activation=deepcopy(activation), + ) + ) + # weight_init.c2_xavier_fill(self.convs[-1]) + nn.init.kaiming_uniform_(self.convs[-1].weight, a=1) + if self.convs[-1].bias is not None: + nn.init.constant_(self.convs[-1].bias, 0) + # atrous convs + for dilation in dilations: + if use_depthwise_separable_conv: + self.convs.append( + DepthwiseSeparableConv2d( + in_channels, + out_channels, + kernel_size=3, + padding=dilation, + dilation=dilation, + norm1=norm, + activation1=deepcopy(activation), + norm2=norm, + activation2=deepcopy(activation), + ) + ) + else: + self.convs.append( + Conv2d( + in_channels, + out_channels, + kernel_size=3, + padding=dilation, + dilation=dilation, + bias=use_bias, + norm=get_norm(norm, out_channels), + activation=deepcopy(activation), + ) + ) + # weight_init.c2_xavier_fill(self.convs[-1]) + nn.init.kaiming_uniform_(self.convs[-1].weight, a=1) + if self.convs[-1].bias is not None: + nn.init.constant_(self.convs[-1].bias, 0) + # image pooling + # We do not add BatchNorm because the spatial resolution is 1x1, + # the original TF implementation has BatchNorm. + if pool_kernel_size is None: + image_pooling = nn.Sequential( + nn.AdaptiveAvgPool2d(1), + Conv2d( + in_channels, + out_channels, + 1, + bias=True, + activation=deepcopy(activation), + ), + ) + else: + image_pooling = nn.Sequential( + nn.AvgPool2d(kernel_size=pool_kernel_size, stride=1), + Conv2d( + in_channels, + out_channels, + 1, + bias=True, + activation=deepcopy(activation), + ), + ) + # weight_init.c2_xavier_fill(image_pooling[1]) + nn.init.kaiming_uniform_(image_pooling[1].weight, a=1) + if image_pooling[1].bias is not None: + nn.init.constant_(image_pooling[1].bias, 0) + self.convs.append(image_pooling) + + self.project = Conv2d( + 5 * out_channels, + out_channels, + kernel_size=1, + bias=use_bias, + norm=get_norm(norm, out_channels), + activation=deepcopy(activation), + ) + # weight_init.c2_xavier_fill(self.project) + nn.init.kaiming_uniform_(self.project.weight, a=1) + if self.project.bias is not None: + nn.init.constant_(self.project.bias, 0) + + def forward(self, x): + size = x.shape[-2:] + if self.pool_kernel_size is not None: + if size[0] % self.pool_kernel_size[0] or size[1] % self.pool_kernel_size[1]: + raise ValueError( + "`pool_kernel_size` must be divisible by the shape of inputs. " + "Input size: {} `pool_kernel_size`: {}".format(size, self.pool_kernel_size) + ) + res = [] + for conv in self.convs: + res.append(conv(x)) + res[-1] = F.interpolate(res[-1], size=size, mode="bilinear", align_corners=False) + res = torch.cat(res, dim=1) + res = self.project(res) + res = F.dropout(res, self.dropout, training=self.training) if self.dropout > 0 else res + return res diff --git a/focoos/nn/layers/attention.py b/focoos/nn/layers/attention.py new file mode 100644 index 00000000..1cf75734 --- /dev/null +++ b/focoos/nn/layers/attention.py @@ -0,0 +1,437 @@ +import warnings +from typing import Optional + +import torch +import torch.nn as nn + + +class MultiheadAttention(nn.Module): + """A wrapper for ``torch.nn.MultiheadAttention`` + + Implemente MultiheadAttention with identity connection, + and position embedding is also passed as input. + + Args: + embed_dim (int): The embedding dimension for attention. + num_heads (int): The number of attention heads. + attn_drop (float): A Dropout layer on attn_output_weights. + Default: 0.0. + proj_drop (float): A Dropout layer after `MultiheadAttention`. + Default: 0.0. + batch_first (bool): if `True`, then the input and output tensor will be + provided as `(bs, n, embed_dim)`. Default: False. `(n, bs, embed_dim)` + """ + + def __init__( + self, + embed_dim: int, + num_heads: int, + attn_drop: float = 0.0, + proj_drop: float = 0.0, + batch_first: bool = False, + **kwargs, + ): + super().__init__() + self.embed_dim = embed_dim + self.num_heads = num_heads + self.batch_first = batch_first + + self.attn = nn.MultiheadAttention( + embed_dim=embed_dim, + num_heads=num_heads, + dropout=attn_drop, + batch_first=batch_first, + **kwargs, + ) + + self.proj_drop = nn.Dropout(proj_drop) + + def forward( + self, + query: torch.Tensor, + key: Optional[torch.Tensor] = None, + value: Optional[torch.Tensor] = None, + identity: Optional[torch.Tensor] = None, + query_pos: Optional[torch.Tensor] = None, + key_pos: Optional[torch.Tensor] = None, + attn_mask: Optional[torch.Tensor] = None, + key_padding_mask: Optional[torch.Tensor] = None, + **kwargs, + ) -> torch.Tensor: + """Forward function for `MultiheadAttention` + + **kwargs allow passing a more general data flow when combining + with other operations in `transformerlayer`. + + Args: + query (torch.Tensor): Query embeddings with shape + `(num_query, bs, embed_dim)` if self.batch_first is False, + else `(bs, num_query, embed_dim)` + key (torch.Tensor): Key embeddings with shape + `(num_key, bs, embed_dim)` if self.batch_first is False, + else `(bs, num_key, embed_dim)` + value (torch.Tensor): Value embeddings with the same shape as `key`. + Same in `torch.nn.MultiheadAttention.forward`. Default: None. + If None, the `key` will be used. + identity (torch.Tensor): The tensor, with the same shape as x, will + be used for identity addition. Default: None. + If None, `query` will be used. + query_pos (torch.Tensor): The position embedding for query, with the + same shape as `query`. Default: None. + key_pos (torch.Tensor): The position embedding for key. Default: None. + If None, and `query_pos` has the same shape as `key`, then `query_pos` + will be used for `key_pos`. + attn_mask (torch.Tensor): ByteTensor mask with shape `(num_query, num_key)`. + Same as `torch.nn.MultiheadAttention.forward`. Default: None. + key_padding_mask (torch.Tensor): ByteTensor with shape `(bs, num_key)` which + indicates which elements within `key` to be ignored in attention. + Default: None. + """ + if key is None: + key = query + if value is None: + value = key + if identity is None: + identity = query + if key_pos is None: + if query_pos is not None: + # use query_pos if key_pos is not available + if query_pos.shape == key.shape: + key_pos = query_pos + else: + warnings.warn(f"position encoding of key ismissing in {self.__class__.__name__}.") + if query_pos is not None: + query = query + query_pos + if key_pos is not None: + key = key + key_pos + + out = self.attn( + query=query, + key=key, + value=value, + attn_mask=attn_mask, + key_padding_mask=key_padding_mask, + )[0] + + return identity + self.proj_drop(out) + + +class ConditionalSelfAttention(nn.Module): + """Conditional Self-Attention Module used in Conditional-DETR + + `Conditional DETR for Fast Training Convergence. + `_ + + + Args: + embed_dim (int): The embedding dimension for attention. + num_heads (int): The number of attention heads. + attn_drop (float): A Dropout layer on attn_output_weights. + Default: 0.0. + proj_drop (float): A Dropout layer after `MultiheadAttention`. + Default: 0.0. + batch_first (bool): if `True`, then the input and output tensor will be + provided as `(bs, n, embed_dim)`. Default: False. `(n, bs, embed_dim)` + """ + + def __init__( + self, + embed_dim, + num_heads, + attn_drop=0.0, + proj_drop=0.0, + batch_first=False, + **kwargs, + ): + super().__init__() + self.query_content_proj = nn.Linear(embed_dim, embed_dim) + self.query_pos_proj = nn.Linear(embed_dim, embed_dim) + self.key_content_proj = nn.Linear(embed_dim, embed_dim) + self.key_pos_proj = nn.Linear(embed_dim, embed_dim) + self.value_proj = nn.Linear(embed_dim, embed_dim) + self.out_proj = nn.Linear(embed_dim, embed_dim) + self.attn_drop = nn.Dropout(attn_drop) + self.proj_drop = nn.Dropout(proj_drop) + self.num_heads = num_heads + self.embed_dim = embed_dim + head_dim = embed_dim // num_heads + self.scale = head_dim**-0.5 + self.batch_first = batch_first + + def forward( + self, + query, + key=None, + value=None, + identity=None, + query_pos=None, + key_pos=None, + attn_mask=None, + key_padding_mask=None, + **kwargs, + ): + """Forward function for `ConditionalSelfAttention` + + **kwargs allow passing a more general data flow when combining + with other operations in `transformerlayer`. + + Args: + query (torch.Tensor): Query embeddings with shape + `(num_query, bs, embed_dim)` if self.batch_first is False, + else `(bs, num_query, embed_dim)` + key (torch.Tensor): Key embeddings with shape + `(num_key, bs, embed_dim)` if self.batch_first is False, + else `(bs, num_key, embed_dim)` + value (torch.Tensor): Value embeddings with the same shape as `key`. + Same in `torch.nn.MultiheadAttention.forward`. Default: None. + If None, the `key` will be used. + identity (torch.Tensor): The tensor, with the same shape as `query``, + which will be used for identity addition. Default: None. + If None, `query` will be used. + query_pos (torch.Tensor): The position embedding for query, with the + same shape as `query`. Default: None. + key_pos (torch.Tensor): The position embedding for key. Default: None. + If None, and `query_pos` has the same shape as `key`, then `query_pos` + will be used for `key_pos`. + attn_mask (torch.Tensor): ByteTensor mask with shape `(num_query, num_key)`. + Same as `torch.nn.MultiheadAttention.forward`. Default: None. + key_padding_mask (torch.Tensor): ByteTensor with shape `(bs, num_key)` which + indicates which elements within `key` to be ignored in attention. + Default: None. + """ + if key is None: + key = query + if value is None: + value = key + if identity is None: + identity = query + if key_pos is None: + if query_pos is not None: + # use query_pos if key_pos is not available + if query_pos.shape == key.shape: + key_pos = query_pos + else: + warnings.warn(f"position encoding of key ismissing in {self.__class__.__name__}.") + + assert query_pos is not None and key_pos is not None, ( + "query_pos and key_pos must be passed into ConditionalAttention Module" + ) + + # transpose (b n c) to (n b c) for attention calculation + if self.batch_first: + query = query.transpose(0, 1) # (n b c) + key = key.transpose(0, 1) + value = value.transpose(0, 1) + query_pos = query_pos.transpose(0, 1) + key_pos = key_pos.transpose(0, 1) + identity = identity.transpose(0, 1) + + # query/key/value content and position embedding projection + query_content = self.query_content_proj(query) + query_pos = self.query_pos_proj(query_pos) + key_content = self.key_content_proj(key) + key_pos = self.key_pos_proj(key_pos) + value = self.value_proj(value) + + # attention calculation + N, B, C = query_content.shape + q = query_content + query_pos + k = key_content + key_pos + v = value + + q = q.reshape(N, B, self.num_heads, C // self.num_heads).permute(1, 2, 0, 3) # (B, num_heads, N, head_dim) + k = k.reshape(N, B, self.num_heads, C // self.num_heads).permute(1, 2, 0, 3) + v = v.reshape(N, B, self.num_heads, C // self.num_heads).permute(1, 2, 0, 3) + + q = q * self.scale + attn = q @ k.transpose(-2, -1) + + # add attention mask + if attn_mask is not None: + if attn_mask.dtype == torch.bool: + attn.masked_fill_(attn_mask, float("-inf")) + else: + attn += attn_mask + if key_padding_mask is not None: + attn = attn.masked_fill_(key_padding_mask.unsqueeze(1).unsqueeze(2), float("-inf")) + + attn = attn.softmax(dim=-1) + attn = self.attn_drop(attn) + + out = (attn @ v).transpose(1, 2).reshape(B, N, C) + out = self.out_proj(out) + + if not self.batch_first: + out = out.transpose(0, 1) + return identity + self.proj_drop(out) + + +class ConditionalCrossAttention(nn.Module): + """Conditional Cross-Attention Module used in Conditional-DETR + + `Conditional DETR for Fast Training Convergence. + `_ + + + Args: + embed_dim (int): The embedding dimension for attention. + num_heads (int): The number of attention heads. + attn_drop (float): A Dropout layer on attn_output_weights. + Default: 0.0. + proj_drop (float): A Dropout layer after `MultiheadAttention`. + Default: 0.0. + batch_first (bool): if `True`, then the input and output tensor will be + provided as `(bs, n, embed_dim)`. Default: False. `(n, bs, embed_dim)` + """ + + def __init__( + self, + embed_dim, + num_heads, + attn_drop=0.0, + proj_drop=0.0, + batch_first=False, + **kwargs, + ): + super().__init__() + self.query_content_proj = nn.Linear(embed_dim, embed_dim) + self.query_pos_proj = nn.Linear(embed_dim, embed_dim) + self.query_pos_sine_proj = nn.Linear(embed_dim, embed_dim) + self.key_content_proj = nn.Linear(embed_dim, embed_dim) + self.key_pos_proj = nn.Linear(embed_dim, embed_dim) + self.value_proj = nn.Linear(embed_dim, embed_dim) + self.out_proj = nn.Linear(embed_dim, embed_dim) + self.attn_drop = nn.Dropout(attn_drop) + self.proj_drop = nn.Dropout(proj_drop) + self.num_heads = num_heads + self.batch_first = batch_first + + def forward( + self, + query, + key=None, + value=None, + identity=None, + query_pos=None, + key_pos=None, + query_sine_embed=None, + is_first_layer=False, + attn_mask=None, + key_padding_mask=None, + **kwargs, + ): + """Forward function for `ConditionalCrossAttention` + + **kwargs allow passing a more general data flow when combining + with other operations in `transformerlayer`. + + Args: + query (torch.Tensor): Query embeddings with shape + `(num_query, bs, embed_dim)` if self.batch_first is False, + else `(bs, num_query, embed_dim)` + key (torch.Tensor): Key embeddings with shape + `(num_key, bs, embed_dim)` if self.batch_first is False, + else `(bs, num_key, embed_dim)` + value (torch.Tensor): Value embeddings with the same shape as `key`. + Same in `torch.nn.MultiheadAttention.forward`. Default: None. + If None, the `key` will be used. + identity (torch.Tensor): The tensor, with the same shape as x, will + be used for identity addition. Default: None. + If None, `query` will be used. + query_pos (torch.Tensor): The position embedding for query, with the + same shape as `query`. Default: None. + key_pos (torch.Tensor): The position embedding for key. Default: None. + If None, and `query_pos` has the same shape as `key`, then `query_pos` + will be used for `key_pos`. + query_sine_embed (torch.Tensor): None + is_first_layer (bool): None + attn_mask (torch.Tensor): ByteTensor mask with shape `(num_query, num_key)`. + Same as `torch.nn.MultiheadAttention.forward`. Default: None. + key_padding_mask (torch.Tensor): ByteTensor with shape `(bs, num_key)` which + indicates which elements within `key` to be ignored in attention. + Default: None. + """ + if key is None: + key = query + if value is None: + value = key + if identity is None: + identity = query + if key_pos is None: + if query_pos is not None: + # use query_pos if key_pos is not available + if query_pos.shape == key.shape: + key_pos = query_pos + else: + warnings.warn(f"position encoding of key ismissing in {self.__class__.__name__}.") + + assert query_pos is not None and key_pos is not None, ( + "query_pos and key_pos must be passed into ConditionalAttention Module" + ) + + # transpose (b n c) to (n b c) for attention calculation + if self.batch_first: + query = query.transpose(0, 1) # (n b c) + key = key.transpose(0, 1) + value = value.transpose(0, 1) + query_pos = query_pos.transpose(0, 1) + key_pos = key_pos.transpose(0, 1) + identity = identity.transpose(0, 1) + + # content projection + query_content = self.query_content_proj(query) + key_content = self.key_content_proj(key) + value = self.value_proj(value) + + # shape info + N, B, C = query_content.shape + HW, _, _ = key_content.shape + + # position projection + key_pos = self.key_pos_proj(key_pos) + if is_first_layer: + query_pos = self.query_pos_proj(query_pos) + q = query_content + query_pos + k = key_content + key_pos + else: + q = query_content + k = key_content + v = value + + # preprocess + q = q.view(N, B, self.num_heads, C // self.num_heads) + query_sine_embed = self.query_pos_sine_proj(query_sine_embed).view(N, B, self.num_heads, C // self.num_heads) + q = torch.cat([q, query_sine_embed], dim=3).view(N, B, C * 2) + + k = k.view(HW, B, self.num_heads, C // self.num_heads) # N, 16, 256 + key_pos = key_pos.view(HW, B, self.num_heads, C // self.num_heads) + k = torch.cat([k, key_pos], dim=3).view(HW, B, C * 2) + + # attention calculation + q = q.reshape(N, B, self.num_heads, C * 2 // self.num_heads).permute(1, 2, 0, 3) # (B, num_heads, N, head_dim) + k = k.reshape(HW, B, self.num_heads, C * 2 // self.num_heads).permute(1, 2, 0, 3) + v = v.reshape(HW, B, self.num_heads, C // self.num_heads).permute(1, 2, 0, 3) + + scale = (C * 2 // self.num_heads) ** -0.5 + q = q * scale + attn = q @ k.transpose(-2, -1) + + # add attention mask + if attn_mask is not None: + if attn_mask.dtype == torch.bool: + attn.masked_fill_(attn_mask, float("-inf")) + else: + attn += attn_mask + if key_padding_mask is not None: + attn = attn.masked_fill_(key_padding_mask.unsqueeze(1).unsqueeze(2), float("-inf")) + + attn = attn.softmax(dim=-1) + attn = self.attn_drop(attn) + + out = (attn @ v).transpose(1, 2).reshape(B, N, C) + out = self.out_proj(out) + + if not self.batch_first: + out = out.transpose(0, 1) + + return identity + self.proj_drop(out) diff --git a/focoos/nn/layers/base.py b/focoos/nn/layers/base.py new file mode 100644 index 00000000..8d124d99 --- /dev/null +++ b/focoos/nn/layers/base.py @@ -0,0 +1,132 @@ +import torch +import torch.nn as nn +import torch.nn.functional as F + + +def _get_activation_fn(activation=None): + """Return an activation function given a string""" + if activation is None: + return nn.Identity() + activation = activation.lower() + if activation == "relu": + return F.relu + if activation == "gelu": + return F.gelu + if activation == "glu": + return F.glu + if activation == "silu": + return F.silu + if activation == "leaky_relu": + return F.leaky_relu + + raise RuntimeError(f"activation should be [relu/gelu/glu/silu/leaky_relu], not {activation}.") + + +class MLP(nn.Module): + """The implementation of simple multi-layer perceptron layer + without dropout and identity connection. + + The feature process order follows `Linear -> ReLU -> Linear -> ReLU -> ...`. + + Args: + input_dim (int): The input feature dimension. + hidden_dim (int): The hidden dimension of MLPs. + output_dim (int): the output feature dimension of MLPs. + num_layer (int): The number of FC layer used in MLPs. + """ + + def __init__(self, input_dim: int, hidden_dim: int, output_dim: int, num_layers: int): + super().__init__() + self.num_layers = num_layers + h = [hidden_dim] * (num_layers - 1) + self.layers = nn.ModuleList(nn.Linear(n, k) for n, k in zip([input_dim] + h, h + [output_dim])) + + def forward(self, x): + """Forward function of `MLP`. + + Args: + x (torch.Tensor): the input tensor used in `MLP` layers. + + Returns: + torch.Tensor: the forward results of `MLP` layer + """ + for i, layer in enumerate(self.layers): + x = F.relu(layer(x)) if i < self.num_layers - 1 else layer(x) + return x + + +class FFN(nn.Module): + """The implementation of feed-forward networks (FFNs) + with identity connection. + + Args: + embed_dim (int): The feature dimension. Same as + `MultiheadAttention`. Defaults: 256. + feedforward_dim (int): The hidden dimension of FFNs. + Defaults: 1024. + output_dim (int): The output feature dimension of FFNs. + Default: None. If None, the `embed_dim` will be used. + num_fcs (int, optional): The number of fully-connected layers in + FFNs. Default: 2. + activation (nn.Module): The activation layer used in FFNs. + Default: nn.ReLU(inplace=True). + ffn_drop (float, optional): Probability of an element to be + zeroed in FFN. Default 0.0. + add_identity (bool, optional): Whether to add the + identity connection. Default: `True`. + """ + + def __init__( + self, + embed_dim=256, + feedforward_dim=1024, + output_dim=None, + num_fcs=2, + activation=nn.ReLU(inplace=True), + ffn_drop=0.0, + fc_bias=True, + add_identity=True, + ): + super().__init__() + assert num_fcs >= 2, f"num_fcs should be no less than 2. got {num_fcs}." + self.embed_dim = embed_dim + self.feedforward_dim = feedforward_dim + self.num_fcs = num_fcs + self.activation = activation + + output_dim = embed_dim if output_dim is None else output_dim + + layers = [] + in_channels = embed_dim + for _ in range(num_fcs - 1): + layers.append( + nn.Sequential( + nn.Linear(in_channels, feedforward_dim, bias=fc_bias), + self.activation, + nn.Dropout(ffn_drop), + ) + ) + in_channels = feedforward_dim + layers.append(nn.Linear(feedforward_dim, output_dim, bias=fc_bias)) + layers.append(nn.Dropout(ffn_drop)) + self.layers = nn.Sequential(*layers) + self.add_identity = add_identity + + def forward(self, x, identity=None) -> torch.Tensor: + """Forward function of `FFN`. + + Args: + x (torch.Tensor): the input tensor used in `FFN` layers. + identity (torch.Tensor): the tensor with the same shape as `x`, + which will be used for identity addition. Default: None. + if None, `x` will be used. + + Returns: + torch.Tensor: the forward results of `FFN` layer + """ + out = self.layers(x) + if not self.add_identity: + return out + if identity is None: + identity = x + return identity + out diff --git a/focoos/nn/layers/conv.py b/focoos/nn/layers/conv.py new file mode 100644 index 00000000..783b545d --- /dev/null +++ b/focoos/nn/layers/conv.py @@ -0,0 +1,159 @@ +import warnings + +import torch +import torch.nn as nn +import torch.nn.functional as F + +from focoos.utils.env import TORCH_VERSION + +from .base import _get_activation_fn as get_activation +from .norm import get_norm + + +def check_if_dynamo_compiling(): + if TORCH_VERSION >= (2, 1): + from torch._dynamo import is_compiling + + return is_compiling() + else: + return False + + +class Conv2d(torch.nn.Conv2d): + """ + A wrapper around :class:`torch.nn.Conv2d` to support empty inputs and more features. + """ + + def __init__(self, *args, **kwargs): + """ + Extra keyword arguments supported in addition to those in `torch.nn.Conv2d`: + + Args: + norm (nn.Module, optional): a normalization layer + activation (callable(Tensor) -> Tensor): a callable activation function + + It assumes that norm layer is used before activation. + """ + norm = kwargs.pop("norm", None) + activation = kwargs.pop("activation", None) + super().__init__(*args, **kwargs) + + self.norm = norm + self.activation = activation + + def forward(self, x): + # torchscript does not support SyncBatchNorm yet + # https://github.com/pytorch/pytorch/issues/40507 + # and we skip these codes in torchscript since: + # 1. currently we only support torchscript in evaluation mode + # 2. features needed by exporting module to torchscript are added in PyTorch 1.6 or + # later version, `Conv2d` in these PyTorch versions has already supported empty inputs. + if not torch.jit.is_scripting(): + # Dynamo doesn't support context managers yet + is_dynamo_compiling = check_if_dynamo_compiling() + if not is_dynamo_compiling: + with warnings.catch_warnings(record=True): + if x.numel() == 0 and self.training: + # https://github.com/pytorch/pytorch/issues/12013 + assert not isinstance(self.norm, torch.nn.SyncBatchNorm), ( + "SyncBatchNorm does not support empty inputs!" + ) + + x = F.conv2d( + x, + self.weight, + self.bias, + self.stride, + self.padding, + self.dilation, + self.groups, + ) + if self.norm is not None: + x = self.norm(x) + if self.activation is not None: + x = self.activation(x) + return x + + +class ConvNormLayer(nn.Module): + def __init__(self, ch_in, ch_out, kernel_size, stride, padding=None, bias=False, norm="BN", act=None): + super().__init__() + self.conv = nn.Conv2d( + ch_in, + ch_out, + kernel_size, + stride, + padding=(kernel_size - 1) // 2 if padding is None else padding, + bias=bias, + ) + self.norm = get_norm(norm, ch_out) + self.act = nn.Identity() if act is None else get_activation(act) + + def forward(self, x): + x = self.conv(x) + if self.norm is not None: + x = self.norm(x) + if self.act is not None: + x = self.act(x) + return x + + +class DepthwiseSeparableConv2d(nn.Module): + """ + A kxk depthwise convolution + a 1x1 convolution. + + In :paper:`xception`, norm & activation are applied on the second conv. + :paper:`mobilenet` uses norm & activation on both convs. + """ + + def __init__( + self, + in_channels, + out_channels, + kernel_size=3, + padding=1, + dilation=1, + *, + norm1=None, + activation1=None, + norm2=None, + activation2=None, + ): + """ + Args: + norm1, norm2 (str or callable): normalization for the two conv layers. + activation1, activation2 (callable(Tensor) -> Tensor): activation + function for the two conv layers. + """ + super().__init__() + self.depthwise = Conv2d( + in_channels, + in_channels, + kernel_size=kernel_size, + padding=padding, + dilation=dilation, + groups=in_channels, + bias=not norm1, + norm=get_norm(norm1, in_channels), + activation=activation1, + ) + self.pointwise = Conv2d( + in_channels, + out_channels, + kernel_size=1, + bias=not norm2, + norm=get_norm(norm2, out_channels), + activation=activation2, + ) + + # default initialization + nn.init.kaiming_normal_(self.depthwise.weight, mode="fan_out", nonlinearity="relu") + if self.depthwise.bias is not None: + nn.init.constant_(self.depthwise.bias, 0) + + nn.init.kaiming_normal_(self.pointwise.weight, mode="fan_out", nonlinearity="relu") + if self.pointwise.bias is not None: + nn.init.constant_(self.pointwise.bias, 0) + + def forward(self, x): + return self.pointwise(self.depthwise(x)) diff --git a/focoos/nn/layers/dcn.py b/focoos/nn/layers/dcn.py new file mode 100644 index 00000000..c0ec8946 --- /dev/null +++ b/focoos/nn/layers/dcn.py @@ -0,0 +1,74 @@ +import torch +import torchvision.ops +from torch import nn + + +class DeformableConv2d(nn.Module): + def __init__(self, in_channels, out_channels, kernel_size=3, stride=1, padding=1, dilation=1, bias=False): + super(DeformableConv2d, self).__init__() + + assert type(kernel_size) is tuple or type(kernel_size) is int + + kernel_size = kernel_size if type(kernel_size) is tuple else (kernel_size, kernel_size) + self.stride = stride if type(stride) is tuple else (stride, stride) + self.padding = padding + self.dilation = dilation + self.bias = bias + + self.offset_conv = nn.Conv2d( + in_channels, + 2 * kernel_size[0] * kernel_size[1], + kernel_size=kernel_size, + stride=stride, + padding=self.padding, + dilation=self.dilation, + bias=self.bias, + ) + + nn.init.constant_(self.offset_conv.weight, 0.0) + if self.bias: + nn.init.constant_(self.offset_conv.bias, 0.0) + + self.modulator_conv = nn.Conv2d( + in_channels, + 1 * kernel_size[0] * kernel_size[1], + kernel_size=kernel_size, + stride=stride, + padding=self.padding, + dilation=self.dilation, + bias=self.bias, + ) + + nn.init.constant_(self.modulator_conv.weight, 0.0) + if self.bias: + nn.init.constant_(self.modulator_conv.bias, 0.0) + + self.regular_conv = nn.Conv2d( + in_channels=in_channels, + out_channels=out_channels, + kernel_size=kernel_size, + stride=stride, + padding=self.padding, + dilation=self.dilation, + bias=self.bias, + ) + + def forward(self, x): + # h, w = x.shape[2:] + # max_offset = max(h, w)/4. + + offset = self.offset_conv(x) # .clamp(-max_offset, max_offset) + modulator = 2.0 * torch.sigmoid(self.modulator_conv(x)) + # op = (n - (k * d - 1) + 2p / s) + x = torchvision.ops.deform_conv2d( + input=x, + offset=offset, + weight=self.regular_conv.weight, + bias=self.regular_conv.bias, + padding=self.padding, + mask=modulator, + stride=self.stride, + dilation=self.dilation, + ) + + return x diff --git a/focoos/nn/layers/deformable.py b/focoos/nn/layers/deformable.py new file mode 100644 index 00000000..81279148 --- /dev/null +++ b/focoos/nn/layers/deformable.py @@ -0,0 +1,391 @@ +import math +from typing import Optional + +import torch +import torch.nn as nn +import torch.nn.functional as F +from torch.nn.init import constant_, xavier_uniform_ + + +def ms_deform_attn_core_pytorch(value, value_spatial_shapes, sampling_locations, attention_weights): + # for debug and test only, + # need to use cuda version instead + N_, S_, M_, D_ = value.shape + _, Lq_, M_, L_, P_, _ = sampling_locations.shape + value_list = value.split([H_ * W_ for H_, W_ in value_spatial_shapes], dim=1) + sampling_grids = 2 * sampling_locations - 1 + sampling_value_list = [] + for lid_, (H_, W_) in enumerate(value_spatial_shapes): + # N_, H_*W_, M_, D_ -> N_, H_*W_, M_*D_ -> N_, M_*D_, H_*W_ -> N_*M_, D_, H_, W_ + value_l_ = value_list[lid_].flatten(2).transpose(1, 2).reshape(N_ * M_, D_, H_, W_) + # N_, Lq_, M_, P_, 2 -> N_, M_, Lq_, P_, 2 -> N_*M_, Lq_, P_, 2 + sampling_grid_l_ = sampling_grids[:, :, :, lid_].transpose(1, 2).flatten(0, 1) + # N_*M_, D_, Lq_, P_ + sampling_value_l_ = F.grid_sample( + value_l_, + sampling_grid_l_, + mode="bilinear", + padding_mode="zeros", + align_corners=False, + ) + sampling_value_list.append(sampling_value_l_) + # (N_, Lq_, M_, L_, P_) -> (N_, M_, Lq_, L_, P_) -> (N_, M_, 1, Lq_, L_*P_) + attention_weights = attention_weights.transpose(1, 2).reshape(N_ * M_, 1, Lq_, L_ * P_) + output = (torch.stack(sampling_value_list, dim=-2).flatten(-2) * attention_weights).sum(-1).view(N_, M_ * D_, Lq_) + return output.transpose(1, 2).contiguous() + + +class MSDeformAttn(nn.Module): + def __init__(self, d_model=256, n_levels=4, n_heads=8, n_points=4): + """ + Multi-Scale Deformable Attention Module + :param d_model hidden dimension + :param n_levels number of feature levels + :param n_heads number of attention heads + :param n_points number of sampling points per attention head per feature level + """ + super().__init__() + if d_model % n_heads != 0: + raise ValueError("d_model must be divisible by n_heads, but got {} and {}".format(d_model, n_heads)) + _d_per_head = d_model // n_heads + # you'd better set _d_per_head to a power of 2 which is more efficient in our CUDA implementation + self.im2col_step = 128 + + self.d_model = d_model + self.n_levels = n_levels + self.n_heads = n_heads + self.n_points = n_points + + self.sampling_offsets = nn.Linear(d_model, n_heads * n_levels * n_points * 2) + self.attention_weights = nn.Linear(d_model, n_heads * n_levels * n_points) + self.value_proj = nn.Linear(d_model, d_model) + self.output_proj = nn.Linear(d_model, d_model) + + self._reset_parameters() + + def _reset_parameters(self): + constant_(self.sampling_offsets.weight.data, 0.0) + thetas = torch.arange(self.n_heads, dtype=torch.float32) * (2.0 * math.pi / self.n_heads) + grid_init = torch.stack([thetas.cos(), thetas.sin()], -1) + grid_init = ( + (grid_init / grid_init.abs().max(-1, keepdim=True)[0]) + .view(self.n_heads, 1, 1, 2) + .repeat(1, self.n_levels, self.n_points, 1) + ) + for i in range(self.n_points): + grid_init[:, :, i, :] *= i + 1 + with torch.no_grad(): + self.sampling_offsets.bias = nn.Parameter(grid_init.view(-1)) + constant_(self.attention_weights.weight.data, 0.0) + constant_(self.attention_weights.bias.data, 0.0) + xavier_uniform_(self.value_proj.weight.data) + constant_(self.value_proj.bias.data, 0.0) + xavier_uniform_(self.output_proj.weight.data) + constant_(self.output_proj.bias.data, 0.0) + + def forward( + self, + query, + reference_points, + input_flatten, + input_spatial_shapes, + input_level_start_index, + input_padding_mask=None, + ): + r""" + :param query (N, Length_{query}, C) + :param reference_points (N, Length_{query}, n_levels, 2), range in [0, 1], top-left (0,0), bottom-right (1, 1), including padding area + or (N, Length_{query}, n_levels, 4), add additional (w, h) to form reference boxes + :param input_flatten (N, \sum_{l=0}^{L-1} H_l \cdot W_l, C) + :param input_spatial_shapes (n_levels, 2), [(H_0, W_0), (H_1, W_1), ..., (H_{L-1}, W_{L-1})] + :param input_level_start_index (n_levels, ), [0, H_0*W_0, H_0*W_0+H_1*W_1, H_0*W_0+H_1*W_1+H_2*W_2, ..., H_0*W_0+H_1*W_1+...+H_{L-1}*W_{L-1}] + :param input_padding_mask (N, \sum_{l=0}^{L-1} H_l \cdot W_l), True for padding elements, False for non-padding elements + + :return output (N, Length_{query}, C) + """ + N, Len_q, _ = query.shape + N, Len_in, _ = input_flatten.shape + assert (input_spatial_shapes[:, 0] * input_spatial_shapes[:, 1]).sum() == Len_in + + value = self.value_proj(input_flatten) + if input_padding_mask is not None: + value = value.masked_fill(input_padding_mask[..., None], float(0)) + value = value.view(N, Len_in, self.n_heads, self.d_model // self.n_heads) + sampling_offsets = self.sampling_offsets(query).view(N, Len_q, self.n_heads, self.n_levels, self.n_points, 2) + attention_weights = self.attention_weights(query).view(N, Len_q, self.n_heads, self.n_levels * self.n_points) + attention_weights = F.softmax(attention_weights, -1).view(N, Len_q, self.n_heads, self.n_levels, self.n_points) + # N, Len_q, n_heads, n_levels, n_points, 2 + if reference_points.shape[-1] == 2: + offset_normalizer = torch.stack([input_spatial_shapes[..., 1], input_spatial_shapes[..., 0]], -1) + sampling_locations = ( + reference_points[:, :, None, :, None, :] + + sampling_offsets / offset_normalizer[None, None, None, :, None, :] + ) + elif reference_points.shape[-1] == 4: + sampling_locations = ( + reference_points[:, :, None, :, None, :2] + + sampling_offsets / self.n_points * reference_points[:, :, None, :, None, 2:] * 0.5 + ) + else: + raise ValueError( + "Last dim of reference_points must be 2 or 4, but get {} instead.".format(reference_points.shape[-1]) + ) + # try: + # output = MSDeformAttnFunction.apply( + # value, input_spatial_shapes, input_level_start_index, sampling_locations, attention_weights, self.im2col_step) + # except: + # CPU + output = ms_deform_attn_core_pytorch(value, input_spatial_shapes, sampling_locations, attention_weights) + # # For FLOPs calculation only + # output = ms_deform_attn_core_pytorch(value, input_spatial_shapes, sampling_locations, attention_weights) + output = self.output_proj(output) + return output + + +# FIXME: merge with previous one! Actually I found differences in results +def multi_scale_deformable_attn_pytorch( + value: torch.Tensor, + value_spatial_shapes: torch.Tensor, + sampling_locations: torch.Tensor, + attention_weights: torch.Tensor, +) -> torch.Tensor: + bs, _, num_heads, embed_dims = value.shape + _, num_queries, num_heads, num_levels, num_points, _ = sampling_locations.shape + value_list = value.split([H_ * W_ for H_, W_ in value_spatial_shapes], dim=1) + sampling_grids = 2 * sampling_locations - 1 + sampling_value_list = [] + for level, (H_, W_) in enumerate(value_spatial_shapes): + # bs, H_*W_, num_heads, embed_dims -> + # bs, H_*W_, num_heads*embed_dims -> + # bs, num_heads*embed_dims, H_*W_ -> + # bs*num_heads, embed_dims, H_, W_ + value_l_ = value_list[level].flatten(2).transpose(1, 2).reshape(bs * num_heads, embed_dims, H_, W_) + # bs, num_queries, num_heads, num_points, 2 -> + # bs, num_heads, num_queries, num_points, 2 -> + # bs*num_heads, num_queries, num_points, 2 + sampling_grid_l_ = sampling_grids[:, :, :, level].transpose(1, 2).flatten(0, 1) + # bs*num_heads, embed_dims, num_queries, num_points + sampling_value_l_ = F.grid_sample( + value_l_, + sampling_grid_l_, + mode="bilinear", + padding_mode="zeros", + align_corners=False, + ) + sampling_value_list.append(sampling_value_l_) + # (bs, num_queries, num_heads, num_levels, num_points) -> + # (bs, num_heads, num_queries, num_levels, num_points) -> + # (bs, num_heads, 1, num_queries, num_levels*num_points) + attention_weights = attention_weights.transpose(1, 2).reshape( + bs * num_heads, 1, num_queries, num_levels * num_points + ) + output = ( + (torch.stack(sampling_value_list, dim=-2).flatten(-2) * attention_weights) + .sum(-1) + .view(bs, num_heads * embed_dims, num_queries) + ) + return output.transpose(1, 2).contiguous() + + +# FIXME: merge with previous one! Actually I found differences in results +class MultiScaleDeformableAttention(nn.Module): + """Multi-Scale Deformable Attention Module used in Deformable-DETR + + `Deformable DETR: Deformable Transformers for End-to-End Object Detection. + `_. + + Args: + embed_dim (int): The embedding dimension of Attention. Default: 256. + num_heads (int): The number of attention heads. Default: 8. + num_levels (int): The number of feature map used in Attention. Default: 4. + num_points (int): The number of sampling points for each query + in each head. Default: 4. + img2col_steps (int): The step used in image_to_column. Defualt: 64. + dropout (float): Dropout layer used in output. Default: 0.1. + batch_first (bool): if ``True``, then the input and output tensor will be + provided as `(bs, n, embed_dim)`. Default: False. `(n, bs, embed_dim)` + """ + + def __init__( + self, + embed_dim: int = 256, + num_heads: int = 8, + num_levels: int = 4, + num_points: int = 4, + img2col_step: int = 64, + dropout: float = 0.1, + batch_first: bool = False, + ): + super().__init__() + if embed_dim % num_heads != 0: + raise ValueError("embed_dim must be divisible by num_heads, but got {} and {}".format(embed_dim, num_heads)) + + self.dropout = nn.Dropout(dropout) + self.batch_first = batch_first + + self.im2col_step = img2col_step + self.embed_dim = embed_dim + self.num_heads = num_heads + self.num_levels = num_levels + self.num_points = num_points + # n_heads * n_points and n_levels for multi-level feature inputs + self.sampling_offsets = nn.Linear(embed_dim, num_heads * num_levels * num_points * 2) + self.attention_weights = nn.Linear(embed_dim, num_heads * num_levels * num_points) + self.value_proj = nn.Linear(embed_dim, embed_dim) + self.output_proj = nn.Linear(embed_dim, embed_dim) + + self.init_weights() + + def init_weights(self): + """ + Default initialization for Parameters of Module. + """ + constant_(self.sampling_offsets.weight.data, 0.0) + thetas = torch.arange(self.num_heads, dtype=torch.float32) * (2.0 * math.pi / self.num_heads) + grid_init = torch.stack([thetas.cos(), thetas.sin()], -1) + grid_init = ( + (grid_init / grid_init.abs().max(-1, keepdim=True)[0]) + .view(self.num_heads, 1, 1, 2) + .repeat(1, self.num_levels, self.num_points, 1) + ) + for i in range(self.num_points): + grid_init[:, :, i, :] *= i + 1 + with torch.no_grad(): + self.sampling_offsets.bias = nn.Parameter(grid_init.view(-1)) + constant_(self.attention_weights.weight.data, 0.0) + constant_(self.attention_weights.bias.data, 0.0) + xavier_uniform_(self.value_proj.weight.data) + constant_(self.value_proj.bias.data, 0.0) + xavier_uniform_(self.output_proj.weight.data) + constant_(self.output_proj.bias.data, 0.0) + + def forward( + self, + query: torch.Tensor, + key: Optional[torch.Tensor] = None, + value: Optional[torch.Tensor] = None, + identity: Optional[torch.Tensor] = None, + query_pos: Optional[torch.Tensor] = None, + key_padding_mask: Optional[torch.Tensor] = None, + reference_points: Optional[torch.Tensor] = None, + spatial_shapes: Optional[torch.Tensor] = None, + level_start_index: Optional[torch.Tensor] = None, + **kwargs, + ) -> torch.Tensor: + """Forward Function of MultiScaleDeformableAttention + + Args: + query (torch.Tensor): Query embeddings with shape + `(num_query, bs, embed_dim)` + key (torch.Tensor): Key embeddings with shape + `(num_key, bs, embed_dim)` + value (torch.Tensor): Value embeddings with shape + `(num_key, bs, embed_dim)` + identity (torch.Tensor): The tensor used for addition, with the + same shape as `query`. Default: None. If None, `query` will be + used. + query_pos (torch.Tensor): The position embedding for `query`. Default: None. + key_padding_mask (torch.Tensor): ByteTensor for `query`, with shape `(bs, num_key)`, + indicating which elements within `key` to be ignored in attention. + reference_points (torch.Tensor): The normalized reference points + with shape `(bs, num_query, num_levels, 2)`, + all elements is range in [0, 1], top-left (0, 0), + bottom-right (1, 1), including padding are. + or `(N, Length_{query}, num_levels, 4)`, add additional + two dimensions `(h, w)` to form reference boxes. + spatial_shapes (torch.Tensor): Spatial shape of features in different levels. + With shape `(num_levels, 2)`, last dimension represents `(h, w)`. + level_start_index (torch.Tensor): The start index of each level. A tensor with + shape `(num_levels, )` which can be represented as + `[0, h_0 * w_0, h_0 * w_0 + h_1 * w_1, ...]`. + + Returns: + torch.Tensor: forward results with shape `(num_query, bs, embed_dim)` + """ + + if value is None: + value = query + + if identity is None: + identity = query + if query_pos is not None: + query = query + query_pos + + if not self.batch_first: + # change to (bs, num_query ,embed_dims) + query = query.permute(1, 0, 2) + value = value.permute(1, 0, 2) + + bs, num_query, _ = query.shape + bs, num_value, _ = value.shape + + assert (spatial_shapes[:, 0] * spatial_shapes[:, 1]).sum() == num_value + + # value projection + value = self.value_proj(value) + # fill "0" for the padding part + if key_padding_mask is not None: + value = value.masked_fill(key_padding_mask[..., None], float(0)) + # [bs, all hw, 256] -> [bs, all hw, 8, 32] + value = value.view(bs, num_value, self.num_heads, -1) + # [bs, all hw, 8, 4, 4, 2]: 8 heads, 4 level features, 4 sampling points, 2 offsets + sampling_offsets = self.sampling_offsets(query).view( + bs, num_query, self.num_heads, self.num_levels, self.num_points, 2 + ) + # [bs, all hw, 8, 16]: 4 level 4 sampling points: 16 features total + attention_weights = self.attention_weights(query).view( + bs, num_query, self.num_heads, self.num_levels * self.num_points + ) + attention_weights = attention_weights.softmax(-1) + attention_weights = attention_weights.view( + bs, + num_query, + self.num_heads, + self.num_levels, + self.num_points, + ) + + # bs, num_query, num_heads, num_levels, num_points, 2 + if reference_points.shape[-1] == 2: + # reference_points [bs, all hw, 4, 2] -> [bs, all hw, 1, 4, 1, 2] + # sampling_offsets [bs, all hw, 8, 4, 4, 2] + # offset_normalizer [4, 2] -> [1, 1, 1, 4, 1, 2] + # references_points + sampling_offsets + + offset_normalizer = torch.stack([spatial_shapes[..., 1], spatial_shapes[..., 0]], -1) + sampling_locations = ( + reference_points[:, :, None, :, None, :] + + sampling_offsets / offset_normalizer[None, None, None, :, None, :] + ) + elif reference_points.shape[-1] == 4: + sampling_locations = ( + reference_points[:, :, None, :, None, :2] + + sampling_offsets / self.num_points * reference_points[:, :, None, :, None, 2:] * 0.5 + ) + else: + raise ValueError( + "Last dim of reference_points must be 2 or 4, but get {} instead.".format(reference_points.shape[-1]) + ) + + # # the original impl for fp32 training + # if torch.cuda.is_available() and value.is_cuda: + # output = MultiScaleDeformableAttnFunction.apply( + # value.to(torch.float32) if value.dtype==torch.float16 else value, + # spatial_shapes, + # level_start_index, + # sampling_locations, + # attention_weights, + # self.im2col_step, + # ) + # else: + output = multi_scale_deformable_attn_pytorch(value, spatial_shapes, sampling_locations, attention_weights) + + if value.dtype == torch.float16: + output = output.to(torch.float16) + + output = self.output_proj(output) + + if not self.batch_first: + output = output.permute(1, 0, 2) + + return self.dropout(output) + identity diff --git a/focoos/nn/layers/functional.py b/focoos/nn/layers/functional.py new file mode 100644 index 00000000..a3c5b3e2 --- /dev/null +++ b/focoos/nn/layers/functional.py @@ -0,0 +1,6 @@ +import torch + + +def inverse_sigmoid(x: torch.Tensor, eps: float = 1e-5) -> torch.Tensor: + x = x.clip(min=0.0, max=1.0) + return torch.log(x.clip(min=eps) / (1 - x).clip(min=eps)) diff --git a/focoos/nn/layers/misc.py b/focoos/nn/layers/misc.py new file mode 100644 index 00000000..1f1cc17e --- /dev/null +++ b/focoos/nn/layers/misc.py @@ -0,0 +1,76 @@ +import collections.abc +from itertools import repeat + +import torch.nn as nn + + +# Layer/Module Helpers +# Hacked together by / Copyright 2020 Ross Wightman (TIMM library) +# From torch internals +def _ntuple(n): + def parse(x): + if isinstance(x, collections.abc.Iterable) and not isinstance(x, str): + return tuple(x) + return tuple(repeat(x, n)) + + return parse + + +to_2tuple = _ntuple(2) + + +def drop_path(x, drop_prob: float = 0.0, training: bool = False, scale_by_keep: bool = True): + """Drop paths (Stochastic Depth) per sample (when applied in main path of residual blocks). + + This is the same as the DropConnect implementation for EfficientNet networks, however, + the original name is misleading as 'Drop Connect' is a different form of dropout in a separate paper. + See discussion: https://github.com/tensorflow/tpu/issues/494#issuecomment-532968956 + + Args: + x: Input tensor + drop_prob: Probability of dropping a path + training: Whether in training mode + scale_by_keep: Whether to scale the kept paths to maintain sum + + Returns: + Tensor with paths dropped + """ + if drop_prob == 0.0 or not training: + return x + keep_prob = 1 - drop_prob + shape = (x.shape[0],) + (1,) * (x.ndim - 1) # work with diff dim tensors, not just 2D ConvNets + random_tensor = x.new_empty(shape).bernoulli_(keep_prob) + if keep_prob > 0.0 and scale_by_keep: + random_tensor.div_(keep_prob) + return x * random_tensor + + +# Copyright 2020 TIMM library +class DropPath(nn.Module): + """Drop paths (Stochastic Depth) per sample (when applied in main path of residual blocks).""" + + def __init__(self, drop_prob: float = 0.0, scale_by_keep: bool = True): + """Initialize DropPath module. + + Args: + drop_prob: Probability of dropping a path + scale_by_keep: Whether to scale the kept paths to maintain sum + """ + super(DropPath, self).__init__() + self.drop_prob = drop_prob + self.scale_by_keep = scale_by_keep + + def forward(self, x): + """Apply drop path to input tensor. + + Args: + x: Input tensor + + Returns: + Tensor with paths dropped + """ + return drop_path(x, self.drop_prob, self.training, self.scale_by_keep) + + def extra_repr(self): + """Return string representation of module parameters.""" + return f"drop_prob={round(self.drop_prob, 3):0.3f}" diff --git a/focoos/nn/layers/mvit.py b/focoos/nn/layers/mvit.py new file mode 100644 index 00000000..fa476c3f --- /dev/null +++ b/focoos/nn/layers/mvit.py @@ -0,0 +1,190 @@ +# Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved +import math + +import torch +import torch.nn as nn +import torch.nn.functional as F + +__all__ = [ + "window_partition", + "window_unpartition", + "add_decomposed_rel_pos", + "get_abs_pos", + "PatchEmbed", +] + + +def window_partition(x, window_size): + """ + Partition into non-overlapping windows with padding if needed. + Args: + x (tensor): input tokens with [B, H, W, C]. + window_size (int): window size. + + Returns: + windows: windows after partition with [B * num_windows, window_size, window_size, C]. + (Hp, Wp): padded height and width before partition + """ + B, H, W, C = x.shape + + pad_h = (window_size - H % window_size) % window_size + pad_w = (window_size - W % window_size) % window_size + if pad_h > 0 or pad_w > 0: + x = F.pad(x, (0, 0, 0, pad_w, 0, pad_h)) + Hp, Wp = H + pad_h, W + pad_w + + x = x.view(B, Hp // window_size, window_size, Wp // window_size, window_size, C) + windows = x.permute(0, 1, 3, 2, 4, 5).contiguous().view(-1, window_size, window_size, C) + return windows, (Hp, Wp) + + +def window_unpartition(windows, window_size, pad_hw, hw): + """ + Window unpartition into original sequences and removing padding. + Args: + x (tensor): input tokens with [B * num_windows, window_size, window_size, C]. + window_size (int): window size. + pad_hw (Tuple): padded height and width (Hp, Wp). + hw (Tuple): original height and width (H, W) before padding. + + Returns: + x: unpartitioned sequences with [B, H, W, C]. + """ + Hp, Wp = pad_hw + H, W = hw + B = windows.shape[0] // (Hp * Wp // window_size // window_size) + x = windows.view(B, Hp // window_size, Wp // window_size, window_size, window_size, -1) + x = x.permute(0, 1, 3, 2, 4, 5).contiguous().view(B, Hp, Wp, -1) + + if Hp > H or Wp > W: + x = x[:, :H, :W, :].contiguous() + return x + + +def get_rel_pos(q_size, k_size, rel_pos): + """ + Get relative positional embeddings according to the relative positions of + query and key sizes. + Args: + q_size (int): size of query q. + k_size (int): size of key k. + rel_pos (Tensor): relative position embeddings (L, C). + + Returns: + Extracted positional embeddings according to relative positions. + """ + max_rel_dist = int(2 * max(q_size, k_size) - 1) + # Interpolate rel pos if needed. + if rel_pos.shape[0] != max_rel_dist: + # Interpolate rel pos. + rel_pos_resized = F.interpolate( + rel_pos.reshape(1, rel_pos.shape[0], -1).permute(0, 2, 1), + size=max_rel_dist, + mode="linear", + ) + rel_pos_resized = rel_pos_resized.reshape(-1, max_rel_dist).permute(1, 0) + else: + rel_pos_resized = rel_pos + + # Scale the coords with short length if shapes for q and k are different. + q_coords = torch.arange(q_size)[:, None] * max(k_size / q_size, 1.0) + k_coords = torch.arange(k_size)[None, :] * max(q_size / k_size, 1.0) + relative_coords = (q_coords - k_coords) + (k_size - 1) * max(q_size / k_size, 1.0) + + return rel_pos_resized[relative_coords.long()] + + +def add_decomposed_rel_pos(attn, q, rel_pos_h, rel_pos_w, q_size, k_size): + """ + Calculate decomposed Relative Positional Embeddings from :paper:`mvitv2`. + https://github.com/facebookresearch/mvit/blob/19786631e330df9f3622e5402b4a419a263a2c80/mvit/models/attention.py # noqa B950 + Args: + attn (Tensor): attention map. + q (Tensor): query q in the attention layer with shape (B, q_h * q_w, C). + rel_pos_h (Tensor): relative position embeddings (Lh, C) for height axis. + rel_pos_w (Tensor): relative position embeddings (Lw, C) for width axis. + q_size (Tuple): spatial sequence size of query q with (q_h, q_w). + k_size (Tuple): spatial sequence size of key k with (k_h, k_w). + + Returns: + attn (Tensor): attention map with added relative positional embeddings. + """ + q_h, q_w = q_size + k_h, k_w = k_size + Rh = get_rel_pos(q_h, k_h, rel_pos_h) + Rw = get_rel_pos(q_w, k_w, rel_pos_w) + + B, _, dim = q.shape + r_q = q.reshape(B, q_h, q_w, dim) + rel_h = torch.einsum("bhwc,hkc->bhwk", r_q, Rh) + rel_w = torch.einsum("bhwc,wkc->bhwk", r_q, Rw) + + attn = (attn.view(B, q_h, q_w, k_h, k_w) + rel_h[:, :, :, :, None] + rel_w[:, :, :, None, :]).view( + B, q_h * q_w, k_h * k_w + ) + + return attn + + +def get_abs_pos(abs_pos, has_cls_token, hw): + """ + Calculate absolute positional embeddings. If needed, resize embeddings and remove cls_token + dimension for the original embeddings. + Args: + abs_pos (Tensor): absolute positional embeddings with (1, num_position, C). + has_cls_token (bool): If true, has 1 embedding in abs_pos for cls token. + hw (Tuple): size of input image tokens. + + Returns: + Absolute positional embeddings after processing with shape (1, H, W, C) + """ + h, w = hw + if has_cls_token: + abs_pos = abs_pos[:, 1:] + xy_num = abs_pos.shape[1] + size = int(math.sqrt(xy_num)) + assert size * size == xy_num + + if size != h or size != w: + new_abs_pos = F.interpolate( + abs_pos.reshape(1, size, size, -1).permute(0, 3, 1, 2), + size=(h, w), + mode="bicubic", + align_corners=False, + ) + + return new_abs_pos.permute(0, 2, 3, 1) + else: + return abs_pos.reshape(1, h, w, -1) + + +class PatchEmbed(nn.Module): + """ + Image to Patch Embedding. + """ + + def __init__( + self, + kernel_size=(16, 16), + stride=(16, 16), + padding=(0, 0), + in_chans=3, + embed_dim=768, + ): + """ + Args: + kernel_size (Tuple): kernel size of the projection layer. + stride (Tuple): stride of the projection layer. + padding (Tuple): padding size of the projection layer. + in_chans (int): Number of input image channels. + embed_dim (int): embed_dim (int): Patch embedding dimension. + """ + super().__init__() + + self.proj = nn.Conv2d(in_chans, embed_dim, kernel_size=kernel_size, stride=stride, padding=padding) + + def forward(self, x): + x = self.proj(x) + # B C H W -> B H W C + x = x.permute(0, 2, 3, 1) + return x diff --git a/focoos/nn/layers/norm.py b/focoos/nn/layers/norm.py new file mode 100644 index 00000000..bdb5ce1f --- /dev/null +++ b/focoos/nn/layers/norm.py @@ -0,0 +1,235 @@ +import torch +import torch.nn as nn +from torch.nn import functional as F + + +class FrozenBatchNorm2d(nn.Module): + """ + BatchNorm2d where the batch statistics and the affine parameters are fixed. + + It contains non-trainable buffers called + "weight" and "bias", "running_mean", "running_var", + initialized to perform identity transformation. + + The pre-trained backbone models from Caffe2 only contain "weight" and "bias", + which are computed from the original four parameters of BN. + The affine transform `x * weight + bias` will perform the equivalent + computation of `(x - running_mean) / sqrt(running_var) * weight + bias`. + When loading a backbone model from Caffe2, "running_mean" and "running_var" + will be left unchanged as identity transformation. + + Other pre-trained backbone models may contain all 4 parameters. + + The forward is implemented by `F.batch_norm(..., training=False)`. + """ + + def __init__(self, num_features, eps=1e-5): + super().__init__() + self.num_features = num_features + self.eps = eps + self.register_buffer("weight", torch.ones(num_features)) + self.register_buffer("bias", torch.zeros(num_features)) + self.register_buffer("running_mean", torch.zeros(num_features)) + self.register_buffer("running_var", torch.ones(num_features) - eps) + self.register_buffer("num_batches_tracked", None) + + def forward(self, x): + if x.requires_grad: + # When gradients are needed, F.batch_norm will use extra memory + # because its backward op computes gradients for weight/bias as well. + scale = self.weight * (self.running_var + self.eps).rsqrt() + bias = self.bias - self.running_mean * scale + scale = scale.reshape(1, -1, 1, 1) + bias = bias.reshape(1, -1, 1, 1) + out_dtype = x.dtype # may be half + return x * scale.to(out_dtype) + bias.to(out_dtype) + else: + # When gradients are not needed, F.batch_norm is a single fused op + # and provide more optimization opportunities. + return F.batch_norm( + x, + self.running_mean, + self.running_var, + self.weight, + self.bias, + training=False, + eps=self.eps, + ) + + def _load_from_state_dict( + self, + state_dict, + prefix, + local_metadata, + strict, + missing_keys, + unexpected_keys, + error_msgs, + ): + version = local_metadata.get("version", None) + + if version is None or version < 2: + # No running_mean/var in early versions + # This will silent the warnings + if prefix + "running_mean" not in state_dict: + state_dict[prefix + "running_mean"] = torch.zeros_like(self.running_mean) + if prefix + "running_var" not in state_dict: + state_dict[prefix + "running_var"] = torch.ones_like(self.running_var) + + super()._load_from_state_dict( + state_dict, + prefix, + local_metadata, + strict, + missing_keys, + unexpected_keys, + error_msgs, + ) + + def __repr__(self): + return "FrozenBatchNorm2d(num_features={}, eps={})".format(self.num_features, self.eps) + + @classmethod + def convert_frozen_batchnorm(cls, module): + """ + Convert all BatchNorm/SyncBatchNorm in module into FrozenBatchNorm. + + Args: + module (torch.nn.Module): + + Returns: + If module is BatchNorm/SyncBatchNorm, returns a new module. + Otherwise, in-place convert module and return it. + + Similar to convert_sync_batchnorm in + https://github.com/pytorch/pytorch/blob/master/torch/nn/modules/batchnorm.py + """ + bn_module = nn.modules.batchnorm + bn_module = (bn_module.BatchNorm2d, bn_module.SyncBatchNorm) + res = module + if isinstance(module, bn_module): + res = cls(module.num_features) + if module.affine: + res.weight.data = module.weight.data.clone().detach() + res.bias.data = module.bias.data.clone().detach() + if module.running_mean is not None: + res.running_mean.data = module.running_mean.data + if module.running_var is not None: + res.running_var.data = module.running_var.data + res.eps = module.eps + if module.num_batches_tracked is not None: + res.num_batches_tracked = module.num_batches_tracked + else: + for name, child in module.named_children(): + new_child = cls.convert_frozen_batchnorm(child) + if new_child is not child: + res.add_module(name, new_child) + return res + + @classmethod + def convert_frozenbatchnorm2d_to_batchnorm2d(cls, module: nn.Module) -> nn.Module: + """ + Convert all FrozenBatchNorm2d to BatchNorm2d + + Args: + module (torch.nn.Module): + + Returns: + If module is FrozenBatchNorm2d, returns a new module. + Otherwise, in-place convert module and return it. + + This is needed for quantization: + https://fb.workplace.com/groups/1043663463248667/permalink/1296330057982005/ + """ + + res = module + if isinstance(module, FrozenBatchNorm2d): + res = torch.nn.BatchNorm2d(module.num_features, module.eps) + + res.weight.data = module.weight.data.clone().detach() + res.bias.data = module.bias.data.clone().detach() + if module.running_mean is not None and res.running_mean is not None: + res.running_mean.data = module.running_mean.data.clone().detach() + if module.running_var is not None and res.running_var is not None: + res.running_var.data = module.running_var.data.clone().detach() + res.eps = module.eps + res.num_batches_tracked = module.num_batches_tracked + else: + for name, child in module.named_children(): + new_child = cls.convert_frozenbatchnorm2d_to_batchnorm2d(child) + if new_child is not child: + res.add_module(name, new_child) + return res + + +class LayerNorm(nn.Module): + """LayerNorm that supports two data formats: channels_last (default) or channels_first. + + The ordering of the dimensions in the inputs. channels_last corresponds to inputs with + shape (batch_size, height, width, channels) while channels_first corresponds to inputs + with shape (batch_size, channels, height, width). + """ + + def __init__(self, normalized_shape, eps=1e-6, data_format="channels_last"): + """Initialize LayerNorm module. + + Args: + normalized_shape: Shape of the tensor to be normalized + eps: Small constant for numerical stability + data_format: Format of input tensor ('channels_last' or 'channels_first') + """ + super().__init__() + self.weight = nn.Parameter(torch.ones(normalized_shape)) + self.bias = nn.Parameter(torch.zeros(normalized_shape)) + self.eps = eps + self.data_format = data_format + if self.data_format not in ["channels_last", "channels_first"]: + raise NotImplementedError + self.normalized_shape = (normalized_shape,) + + def forward(self, x): + """Apply layer normalization to input tensor. + + Args: + x: Input tensor + + Returns: + Normalized tensor + """ + if self.data_format == "channels_last": + return F.layer_norm(x, self.normalized_shape, self.weight, self.bias, self.eps) + elif self.data_format == "channels_first": + u = x.mean(1, keepdim=True) + s = (x - u).pow(2).mean(1, keepdim=True) + x = (x - u) / torch.sqrt(s + self.eps) + x = self.weight[:, None, None] * x + self.bias[:, None, None] + return x + + +def get_norm(norm, out_channels): + """ + Args: + norm (str or callable): either one of BN, SyncBN, FrozenBN, GN; + or a callable that takes a channel number and returns + the normalization layer as a nn.Module. + + Returns: + nn.Module or None: the normalization layer + """ + if norm is None: + return None + if isinstance(norm, str): + if len(norm) == 0: + return None + norm = { + "BN": nn.BatchNorm2d, + # Fixed in https://github.com/pytorch/pytorch/pull/36382 + "SyncBN": nn.SyncBatchNorm, + "FrozenBN": FrozenBatchNorm2d, + "GN": lambda channels: nn.GroupNorm(32, channels), + # for debugging: + "nnSyncBN": nn.SyncBatchNorm, + # expose stats_mode N as an option to caller, required for zero-len inputs + "LN": lambda channels: LayerNorm(channels), + }[norm] + return norm(out_channels) diff --git a/focoos/nn/layers/point_rend.py b/focoos/nn/layers/point_rend.py new file mode 100644 index 00000000..f6d04b11 --- /dev/null +++ b/focoos/nn/layers/point_rend.py @@ -0,0 +1,272 @@ +# Copyright (c) Facebook, Inc. and its affiliates. +from typing import List + +import torch +from torch.nn import functional as F + +from focoos.structures import BitMasks, Boxes, Instances, shapes_to_tensor + +""" +Shape shorthand in this module: + + N: minibatch dimension size, i.e. the number of RoIs for instance segmenation or the + number of images for semantic segmenation. + R: number of ROIs, combined over all images, in the minibatch + P: number of points +""" + + +def cat(tensors: List[torch.Tensor], dim: int = 0): + """ + Efficient version of torch.cat that avoids a copy if there is only a single element in a list + """ + assert isinstance(tensors, (list, tuple)) + if len(tensors) == 1: + return tensors[0] + return torch.cat(tensors, dim) + + +def point_sample(input, point_coords, **kwargs): + """ + A wrapper around :function:`torch.nn.functional.grid_sample` to support 3D point_coords tensors. + Unlike :function:`torch.nn.functional.grid_sample` it assumes `point_coords` to lie inside + [0, 1] x [0, 1] square. + + Args: + input (Tensor): A tensor of shape (N, C, H, W) that contains features map on a H x W grid. + point_coords (Tensor): A tensor of shape (N, P, 2) or (N, Hgrid, Wgrid, 2) that contains + [0, 1] x [0, 1] normalized point coordinates. + + Returns: + output (Tensor): A tensor of shape (N, C, P) or (N, C, Hgrid, Wgrid) that contains + features for points in `point_coords`. The features are obtained via bilinear + interplation from `input` the same way as :function:`torch.nn.functional.grid_sample`. + """ + add_dim = False + if point_coords.dim() == 3: + add_dim = True + point_coords = point_coords.unsqueeze(2) + output = F.grid_sample(input, 2.0 * point_coords - 1.0, **kwargs) + if add_dim: + output = output.squeeze(3) + return output + + +def generate_regular_grid_point_coords(R, side_size, device): + """ + Generate regular square grid of points in [0, 1] x [0, 1] coordinate space. + + Args: + R (int): The number of grids to sample, one for each region. + side_size (int): The side size of the regular grid. + device (torch.device): Desired device of returned tensor. + + Returns: + (Tensor): A tensor of shape (R, side_size^2, 2) that contains coordinates + for the regular grids. + """ + aff = torch.tensor([[[0.5, 0, 0.5], [0, 0.5, 0.5]]], device=device) + r = F.affine_grid(aff, torch.Size((1, 1, side_size, side_size)), align_corners=False) + return r.view(1, -1, 2).expand(R, -1, -1) + + +def get_uncertain_point_coords_with_randomness( + coarse_logits, + uncertainty_func, + num_points, + oversample_ratio, + importance_sample_ratio, +): + """ + Sample points in [0, 1] x [0, 1] coordinate space based on their uncertainty. The unceratinties + are calculated for each point using 'uncertainty_func' function that takes point's logit + prediction as input. + See PointRend paper for details. + + Args: + coarse_logits (Tensor): A tensor of shape (N, C, Hmask, Wmask) or (N, 1, Hmask, Wmask) for + class-specific or class-agnostic prediction. + uncertainty_func: A function that takes a Tensor of shape (N, C, P) or (N, 1, P) that + contains logit predictions for P points and returns their uncertainties as a Tensor of + shape (N, 1, P). + num_points (int): The number of points P to sample. + oversample_ratio (int): Oversampling parameter. + importance_sample_ratio (float): Ratio of points that are sampled via importnace sampling. + + Returns: + point_coords (Tensor): A tensor of shape (N, P, 2) that contains the coordinates of P + sampled points. + """ + assert oversample_ratio >= 1 + assert importance_sample_ratio <= 1 and importance_sample_ratio >= 0 + num_boxes = coarse_logits.shape[0] + num_sampled = int(num_points * oversample_ratio) + point_coords = torch.rand(num_boxes, num_sampled, 2, device=coarse_logits.device) + point_logits = point_sample(coarse_logits, point_coords, align_corners=False) + # It is crucial to calculate uncertainty based on the sampled prediction value for the points. + # Calculating uncertainties of the coarse predictions first and sampling them for points leads + # to incorrect results. + # To illustrate this: assume uncertainty_func(logits)=-abs(logits), a sampled point between + # two coarse predictions with -1 and 1 logits has 0 logits, and therefore 0 uncertainty value. + # However, if we calculate uncertainties for the coarse predictions first, + # both will have -1 uncertainty, and the sampled point will get -1 uncertainty. + point_uncertainties = uncertainty_func(point_logits) + num_uncertain_points = int(importance_sample_ratio * num_points) + num_random_points = num_points - num_uncertain_points + idx = torch.topk(point_uncertainties[:, 0, :], k=num_uncertain_points, dim=1)[1] + shift = num_sampled * torch.arange(num_boxes, dtype=torch.long, device=coarse_logits.device) + idx += shift[:, None] + point_coords = point_coords.view(-1, 2)[idx.view(-1), :].view(num_boxes, num_uncertain_points, 2) + if num_random_points > 0: + point_coords = cat( + [ + point_coords, + torch.rand(num_boxes, num_random_points, 2, device=coarse_logits.device), + ], + dim=1, + ) + return point_coords + + +def get_uncertain_point_coords_on_grid(uncertainty_map, num_points): + """ + Find `num_points` most uncertain points from `uncertainty_map` grid. + + Args: + uncertainty_map (Tensor): A tensor of shape (N, 1, H, W) that contains uncertainty + values for a set of points on a regular H x W grid. + num_points (int): The number of points P to select. + + Returns: + point_indices (Tensor): A tensor of shape (N, P) that contains indices from + [0, H x W) of the most uncertain points. + point_coords (Tensor): A tensor of shape (N, P, 2) that contains [0, 1] x [0, 1] normalized + coordinates of the most uncertain points from the H x W grid. + """ + R, _, H, W = uncertainty_map.shape + h_step = 1.0 / float(H) + w_step = 1.0 / float(W) + + num_points = min(H * W, num_points) + point_indices = torch.topk(uncertainty_map.view(R, H * W), k=num_points, dim=1)[1] + point_coords = torch.zeros(R, num_points, 2, dtype=torch.float, device=uncertainty_map.device) + point_coords[:, :, 0] = w_step / 2.0 + (point_indices % W).to(torch.float) * w_step + point_coords[:, :, 1] = h_step / 2.0 + (point_indices // W).to(torch.float) * h_step + return point_indices, point_coords + + +def point_sample_fine_grained_features(features_list, feature_scales, boxes, point_coords): + """ + Get features from feature maps in `features_list` that correspond to specific point coordinates + inside each bounding box from `boxes`. + + Args: + features_list (list[Tensor]): A list of feature map tensors to get features from. + feature_scales (list[float]): A list of scales for tensors in `features_list`. + boxes (list[Boxes]): A list of I Boxes objects that contain R_1 + ... + R_I = R boxes all + together. + point_coords (Tensor): A tensor of shape (R, P, 2) that contains + [0, 1] x [0, 1] box-normalized coordinates of the P sampled points. + + Returns: + point_features (Tensor): A tensor of shape (R, C, P) that contains features sampled + from all features maps in feature_list for P sampled points for all R boxes in `boxes`. + point_coords_wrt_image (Tensor): A tensor of shape (R, P, 2) that contains image-level + coordinates of P points. + """ + cat_boxes = Boxes.cat(boxes) + num_boxes = [b.tensor.size(0) for b in boxes] + + point_coords_wrt_image = get_point_coords_wrt_image(cat_boxes.tensor, point_coords) + split_point_coords_wrt_image = torch.split(point_coords_wrt_image, num_boxes) + + point_features = [] + for idx_img, point_coords_wrt_image_per_image in enumerate(split_point_coords_wrt_image): + point_features_per_image = [] + for idx_feature, feature_map in enumerate(features_list): + h, w = feature_map.shape[-2:] + scale = shapes_to_tensor([w, h]) / feature_scales[idx_feature] + point_coords_scaled = point_coords_wrt_image_per_image / scale.to(feature_map.device) + point_features_per_image.append( + point_sample( + feature_map[idx_img].unsqueeze(0), + point_coords_scaled.unsqueeze(0), + align_corners=False, + ) + .squeeze(0) + .transpose(1, 0) + ) + point_features.append(cat(point_features_per_image, dim=1)) + + return cat(point_features, dim=0), point_coords_wrt_image + + +def get_point_coords_wrt_image(boxes_coords, point_coords): + """ + Convert box-normalized [0, 1] x [0, 1] point cooordinates to image-level coordinates. + + Args: + boxes_coords (Tensor): A tensor of shape (R, 4) that contains bounding boxes. + coordinates. + point_coords (Tensor): A tensor of shape (R, P, 2) that contains + [0, 1] x [0, 1] box-normalized coordinates of the P sampled points. + + Returns: + point_coords_wrt_image (Tensor): A tensor of shape (R, P, 2) that contains + image-normalized coordinates of P sampled points. + """ + with torch.no_grad(): + point_coords_wrt_image = point_coords.clone() + point_coords_wrt_image[:, :, 0] = point_coords_wrt_image[:, :, 0] * ( + boxes_coords[:, None, 2] - boxes_coords[:, None, 0] + ) + point_coords_wrt_image[:, :, 1] = point_coords_wrt_image[:, :, 1] * ( + boxes_coords[:, None, 3] - boxes_coords[:, None, 1] + ) + point_coords_wrt_image[:, :, 0] += boxes_coords[:, None, 0] + point_coords_wrt_image[:, :, 1] += boxes_coords[:, None, 1] + return point_coords_wrt_image + + +def sample_point_labels(instances: Instances, point_coords: torch.Tensor): + """ + Sample point labels from ground truth mask given point_coords. + + Args: + instances (list[Instances]): A list of N Instances, where N is the number of images + in the batch. So, i_th elememt of the list contains R_i objects and R_1 + ... + R_N is + equal to R. The ground-truth gt_masks in each instance will be used to compute labels. + points_coords (Tensor): A tensor of shape (R, P, 2), where R is the total number of + instances and P is the number of points for each instance. The coordinates are in + the absolute image pixel coordinate space, i.e. [0, H] x [0, W]. + + Returns: + Tensor: A tensor of shape (R, P) that contains the labels of P sampled points. + """ + with torch.no_grad(): + gt_mask_logits = [] + point_coords_splits = torch.split( + point_coords, + [len(instances_per_image) for instances_per_image in instances], + ) + for i, instances_per_image in enumerate(instances): + if len(instances_per_image) == 0: + continue + assert isinstance(instances_per_image.masks, BitMasks), ( + "Point head works with GT in 'bitmask' format. Set INPUT.MASK_FORMAT to 'bitmask'." + ) + + gt_bit_masks = instances_per_image.masks.tensor + h, w = instances_per_image.masks.image_size + scale = torch.tensor([w, h], dtype=torch.float, device=gt_bit_masks.device) + points_coord_grid_sample_format = point_coords_splits[i] / scale + gt_mask_logits.append( + point_sample( + gt_bit_masks.to(torch.float32).unsqueeze(1), + points_coord_grid_sample_format, + align_corners=False, + ).squeeze(1) + ) + + point_labels = cat(gt_mask_logits) + return point_labels diff --git a/focoos/nn/layers/position_encoding.py b/focoos/nn/layers/position_encoding.py new file mode 100644 index 00000000..0af7813d --- /dev/null +++ b/focoos/nn/layers/position_encoding.py @@ -0,0 +1,189 @@ +import math + +import torch +from torch import nn + + +class PositionEmbeddingSine(nn.Module): + """Sinusoidal positional embedding module. + + This is a standard version of the position embedding, similar to the one + used by the 'Attention is all you need' paper, generalized to work on images. + """ + + def __init__( + self, + num_pos_feats: int = 64, + temperature: int = 10000, + scale: float = 2 * math.pi, + eps: float = 1e-6, + offset: float = 0.0, + normalize: bool = False, + ): + """Initialize sinusoidal positional embedding. + + Args: + num_pos_feats: Number of positional features + temperature: Temperature parameter for the embedding + scale: Scale factor for normalized coordinates + eps: Small constant for numerical stability + offset: Offset for coordinate normalization + normalize: Whether to normalize coordinates + """ + super().__init__() + if normalize: + assert isinstance(scale, (float, int)), ( + f"when normalize is set, scale should be provided and in float or int type, found {type(scale)}" + ) + self.num_pos_feats = num_pos_feats + self.temperature = temperature + self.normalize = normalize + self.scale = scale + self.eps = eps + self.offset = offset + + def forward(self, x, mask=None): + """Generate positional embeddings for input tensor. + + Args: + x: Input tensor + mask: Optional mask tensor + + Returns: + Positional embedding tensor + """ + if mask is None: + mask = torch.zeros((x.size(0), x.size(2), x.size(3)), device=x.device, dtype=torch.bool) + not_mask = ~mask + y_embed = not_mask.cumsum(1, dtype=torch.float32) + x_embed = not_mask.cumsum(2, dtype=torch.float32) + if self.normalize: + y_embed = (y_embed + self.offset) / (y_embed[:, -1:, :] + self.eps) * self.scale + x_embed = (x_embed + self.offset) / (x_embed[:, :, -1:] + self.eps) * self.scale + + dim_t = torch.arange(self.num_pos_feats, dtype=torch.float32, device=mask.device) + dim_t = self.temperature ** (2 * torch.div(dim_t, 2, rounding_mode="floor") / self.num_pos_feats) + pos_x = x_embed[:, :, :, None] / dim_t + pos_y = y_embed[:, :, :, None] / dim_t + + # use view as mmdet instead of flatten for dynamically exporting to ONNX + B, H, W = mask.size() + pos_x = torch.stack((pos_x[:, :, :, 0::2].sin(), pos_x[:, :, :, 1::2].cos()), dim=4).view(B, H, W, -1) + pos_y = torch.stack((pos_y[:, :, :, 0::2].sin(), pos_y[:, :, :, 1::2].cos()), dim=4).view(B, H, W, -1) + pos = torch.cat((pos_y, pos_x), dim=3).permute(0, 3, 1, 2) + return pos + + def __repr__(self, _repr_indent=4): + """Return string representation of the module.""" + head = "Positional encoding " + self.__class__.__name__ + body = [ + "num_pos_feats: {}".format(self.num_pos_feats), + "temperature: {}".format(self.temperature), + "normalize: {}".format(self.normalize), + "scale: {}".format(self.scale), + ] + # _repr_indent = 4 + lines = [head] + [" " * _repr_indent + line for line in body] + return "\n".join(lines) + + +class PositionEmbeddingLearned(nn.Module): + """ + Position embedding with learnable embedding weights. + + Args: + num_pos_feats (int): The feature dimension for each position along + x-axis or y-axis. The final returned dimension for each position + is 2 times of the input value. + row_num_embed (int, optional): The dictionary size of row embeddings. + Default: 50. + col_num_embed (int, optional): The dictionary size of column embeddings. + Default: 50. + """ + + def __init__( + self, + num_pos_feats: int = 256, + row_num_embed: int = 50, + col_num_embed: int = 50, + ): + super().__init__() + self.row_embed = nn.Embedding(row_num_embed, num_pos_feats) + self.col_embed = nn.Embedding(col_num_embed, num_pos_feats) + self.num_pos_feats = num_pos_feats + self.row_num_embed = row_num_embed + self.col_num_embed = col_num_embed + + self.reset_parameters() + + def reset_parameters(self): + nn.init.uniform_(self.row_embed.weight) + nn.init.uniform_(self.col_embed.weight) + + def forward(self, mask): + """Forward function for `PositionEmbeddingLearned`. + + Args: + mask (torch.Tensor): ByteTensor mask. Non-zero values representing + ignored positions, while zero values means valid positions + for the input tensor. Shape as `(bs, h, w)`. + + Returns: + torch.Tensor: Returned position embedding with + shape `(bs, num_pos_feats * 2, h, w)` + """ + h, w = mask.shape[-2:] + x = torch.arange(w, device=mask.device) + y = torch.arange(h, device=mask.device) + x_emb = self.col_embed(x) + y_emb = self.row_embed(y) + pos = ( + torch.cat( + [ + x_emb.unsqueeze(0).repeat(h, 1, 1), + y_emb.unsqueeze(1).repeat(1, w, 1), + ], + dim=-1, + ) + .permute(2, 0, 1) + .unsqueeze(0) + .repeat(mask.shape[0], 1, 1, 1) + ) + return pos + + +def get_sine_pos_embed( + pos_tensor: torch.Tensor, + num_pos_feats: int = 128, + temperature: int = 10000, + exchange_xy: bool = True, +) -> torch.Tensor: + """generate sine position embedding from a position tensor + + Args: + pos_tensor (torch.Tensor): Shape as `(None, n)`. + num_pos_feats (int): projected shape for each float in the tensor. Default: 128 + temperature (int): The temperature used for scaling + the position embedding. Default: 10000. + exchange_xy (bool, optional): exchange pos x and pos y. \ + For example, input tensor is `[x, y]`, the results will # noqa + be `[pos(y), pos(x)]`. Defaults: True. + + Returns: + torch.Tensor: Returned position embedding # noqa + with shape `(None, n * num_pos_feats)`. + """ + scale = 2 * math.pi + dim_t = torch.arange(num_pos_feats, dtype=torch.float32, device=pos_tensor.device) + dim_t = temperature ** (2 * torch.div(dim_t, 2, rounding_mode="floor") / num_pos_feats) + + def sine_func(x: torch.Tensor): + sin_x = x * scale / dim_t + sin_x = torch.stack((sin_x[:, :, 0::2].sin(), sin_x[:, :, 1::2].cos()), dim=3).flatten(2) + return sin_x + + pos_res = [sine_func(x) for x in pos_tensor.split([1] * pos_tensor.shape[-1], dim=-1)] + if exchange_xy: + pos_res[0], pos_res[1] = pos_res[1], pos_res[0] + pos_res = torch.cat(pos_res, dim=2) + return pos_res diff --git a/focoos/nn/layers/transformer.py b/focoos/nn/layers/transformer.py new file mode 100644 index 00000000..cf660830 --- /dev/null +++ b/focoos/nn/layers/transformer.py @@ -0,0 +1,728 @@ +import copy +from typing import Optional + +import torch +import torch.nn as nn +from torch import Tensor + +from focoos.utils.logger import get_logger + +from .base import _get_activation_fn +from .misc import DropPath +from .norm import LayerNorm + +logger = get_logger(__name__) + + +class SelfAttentionLayer(nn.Module): + """Self-attention layer for transformer architectures.""" + + def __init__(self, d_model, nhead, dropout=0.0, normalize_before=False): + """Initialize self-attention layer. + + Args: + d_model: Dimension of the model + nhead: Number of attention heads + dropout: Dropout probability + normalize_before: Whether to apply normalization before attention + """ + super().__init__() + self.self_attn = nn.MultiheadAttention(d_model, nhead, dropout=dropout) + + self.norm = nn.LayerNorm(d_model) + self.dropout = nn.Dropout(dropout) + + self.activation = nn.ReLU() + self.normalize_before = normalize_before + + self._reset_parameters() + + def _reset_parameters(self): + """Initialize parameters with Xavier uniform distribution.""" + for p in self.parameters(): + if p.dim() > 1: + nn.init.xavier_uniform_(p) + + def with_pos_embed(self, tensor, pos: Optional[Tensor]): + """Add positional embeddings to the tensor. + + Args: + tensor: Input tensor + pos: Positional embedding tensor + + Returns: + Tensor with positional embeddings added + """ + return tensor if pos is None else tensor + pos + + def forward_post( + self, + tgt, + tgt_mask: Optional[Tensor] = None, + tgt_key_padding_mask: Optional[Tensor] = None, + query_pos: Optional[Tensor] = None, + ): + """Apply self-attention with post-normalization. + + Args: + tgt: Target tensor + tgt_mask: Attention mask + tgt_key_padding_mask: Key padding mask + query_pos: Query positional embedding + + Returns: + Output tensor after self-attention + """ + q = k = self.with_pos_embed(tgt, query_pos) + tgt2 = self.self_attn(q, k, value=tgt, attn_mask=tgt_mask, key_padding_mask=tgt_key_padding_mask)[0] + tgt = tgt + self.dropout(tgt2) + tgt = self.norm(tgt) + + return tgt + + def forward_pre( + self, + tgt, + tgt_mask: Optional[Tensor] = None, + tgt_key_padding_mask: Optional[Tensor] = None, + query_pos: Optional[Tensor] = None, + ): + """Apply self-attention with pre-normalization. + + Args: + tgt: Target tensor + tgt_mask: Attention mask + tgt_key_padding_mask: Key padding mask + query_pos: Query positional embedding + + Returns: + Output tensor after self-attention + """ + tgt2 = self.norm(tgt) + q = k = self.with_pos_embed(tgt2, query_pos) + tgt2 = self.self_attn(q, k, value=tgt2, attn_mask=tgt_mask, key_padding_mask=tgt_key_padding_mask)[0] + tgt = tgt + self.dropout(tgt2) + + return tgt + + def forward( + self, + tgt, + tgt_mask: Optional[Tensor] = None, + tgt_key_padding_mask: Optional[Tensor] = None, + query_pos: Optional[Tensor] = None, + ): + """Apply self-attention based on normalization preference. + + Args: + tgt: Target tensor + tgt_mask: Attention mask + tgt_key_padding_mask: Key padding mask + query_pos: Query positional embedding + + Returns: + Output tensor after self-attention + """ + if self.normalize_before: + return self.forward_pre(tgt, tgt_mask, tgt_key_padding_mask, query_pos) + return self.forward_post(tgt, tgt_mask, tgt_key_padding_mask, query_pos) + + +class CrossAttentionLayer(nn.Module): + """Cross-attention layer for transformer architectures.""" + + def __init__(self, d_model, nhead, dropout=0.0, normalize_before=False): + """Initialize cross-attention layer. + + Args: + d_model: Dimension of the model + nhead: Number of attention heads + dropout: Dropout probability + normalize_before: Whether to apply normalization before attention + """ + super().__init__() + self.multihead_attn = nn.MultiheadAttention(embed_dim=d_model, num_heads=nhead, dropout=dropout) + + self.norm = nn.LayerNorm(d_model) + self.dropout = nn.Dropout(dropout) + + self.activation = nn.ReLU() + self.normalize_before = normalize_before + + self._reset_parameters() + + def _reset_parameters(self): + """Initialize parameters with Xavier uniform distribution.""" + for p in self.parameters(): + if p.dim() > 1: + nn.init.xavier_uniform_(p) + + def with_pos_embed(self, tensor, pos: Optional[Tensor]): + """Add positional embeddings to the tensor. + + Args: + tensor: Input tensor + pos: Positional embedding tensor + + Returns: + Tensor with positional embeddings added + """ + return tensor if pos is None else tensor + pos + + def forward_post( + self, + tgt, + memory, + memory_mask: Optional[Tensor] = None, + memory_key_padding_mask: Optional[Tensor] = None, + pos: Optional[Tensor] = None, + query_pos: Optional[Tensor] = None, + ): + """Apply cross-attention with post-normalization. + + Args: + tgt: Target tensor + memory: Memory tensor (key/value) + memory_mask: Attention mask + memory_key_padding_mask: Key padding mask + pos: Memory positional embedding + query_pos: Query positional embedding + + Returns: + Output tensor after cross-attention + """ + tgt2 = self.multihead_attn( + query=self.with_pos_embed(tgt, query_pos), + key=self.with_pos_embed(memory, pos), + value=memory, + attn_mask=memory_mask, + key_padding_mask=memory_key_padding_mask, + )[0] + tgt = tgt + self.dropout(tgt2) + tgt = self.norm(tgt) + + return tgt + + def forward_pre( + self, + tgt, + memory, + memory_mask: Optional[Tensor] = None, + memory_key_padding_mask: Optional[Tensor] = None, + pos: Optional[Tensor] = None, + query_pos: Optional[Tensor] = None, + ): + """Apply cross-attention with pre-normalization. + + Args: + tgt: Target tensor + memory: Memory tensor (key/value) + memory_mask: Attention mask + memory_key_padding_mask: Key padding mask + pos: Memory positional embedding + query_pos: Query positional embedding + + Returns: + Output tensor after cross-attention + """ + tgt2 = self.norm(tgt) + tgt2 = self.multihead_attn( + query=self.with_pos_embed(tgt2, query_pos), + key=self.with_pos_embed(memory, pos), + value=memory, + attn_mask=memory_mask, + key_padding_mask=memory_key_padding_mask, + )[0] + tgt = tgt + self.dropout(tgt2) + + return tgt + + def forward( + self, + tgt, + memory, + memory_mask: Optional[Tensor] = None, + memory_key_padding_mask: Optional[Tensor] = None, + pos: Optional[Tensor] = None, + query_pos: Optional[Tensor] = None, + ): + """Apply cross-attention based on normalization preference. + + Args: + tgt: Target tensor + memory: Memory tensor (key/value) + memory_mask: Attention mask + memory_key_padding_mask: Key padding mask + pos: Memory positional embedding + query_pos: Query positional embedding + + Returns: + Output tensor after cross-attention + """ + if self.normalize_before: + return self.forward_pre(tgt, memory, memory_mask, memory_key_padding_mask, pos, query_pos) + return self.forward_post(tgt, memory, memory_mask, memory_key_padding_mask, pos, query_pos) + + +class FFNLayer(nn.Module): + """Feed-forward network layer for transformer architectures.""" + + def __init__( + self, + d_model, + dim_feedforward=2048, + dropout=0.0, + activation: Optional[str] = None, + normalize_before=False, + ffn_type="standard", + ): + """Initialize feed-forward network layer. + + Args: + d_model: Dimension of the model + dim_feedforward: Dimension of the feedforward network + dropout: Dropout probability + activation: Activation function + normalize_before: Whether to apply normalization before FFN + ffn_type: Type of FFN ('standard' or 'convnext') + """ + super().__init__() + + assert ffn_type in [ + "standard", + "convnext", + ], "FFN can be of 'standard' or 'convnext' type" + self.ffn_type = ffn_type + self.normalize_before = normalize_before + + if self.ffn_type == "standard": + # Implementation of Feedforward model + self.linear1 = nn.Linear(d_model, dim_feedforward) + self.dropout = nn.Dropout(dropout) + self.linear2 = nn.Linear(dim_feedforward, d_model) + + self.norm = nn.LayerNorm(d_model) + + self.activation = _get_activation_fn(activation) if activation is not None else nn.ReLU() + + self._reset_parameters() + elif self.ffn_type == "convnext": + if not normalize_before: + logger.warning( + "Pre-normalizazion is applied by default with convnext FFN layer since " + "it supports only pre-normalization." + ) + self.normalize_before = True + + layer_scale_init_value = 1e-6 + drop_path = 0.0 + + self.dwconv = nn.Conv2d(d_model, d_model, kernel_size=7, padding=3, groups=d_model) # depthwise conv + self.norm = LayerNorm(d_model, eps=1e-6) + # pointwise/1x1 convs, implemented with linear layers + self.pwconv1 = nn.Linear(d_model, dim_feedforward) + self.act = _get_activation_fn(activation) if activation is not None else nn.GELU() + self.pwconv2 = nn.Linear(dim_feedforward, d_model) + self.gamma = ( + nn.Parameter(layer_scale_init_value * torch.ones(d_model), requires_grad=True) + if layer_scale_init_value > 0 + else None + ) + self.drop_path = DropPath(drop_path) if drop_path > 0.0 else nn.Identity() + + def _reset_parameters(self): + """Initialize parameters with Xavier uniform distribution.""" + for p in self.parameters(): + if p.dim() > 1: + nn.init.xavier_uniform_(p) + + def with_pos_embed(self, tensor, pos: Optional[Tensor]): + """Add positional embeddings to the tensor. + + Args: + tensor: Input tensor + pos: Positional embedding tensor + + Returns: + Tensor with positional embeddings added + """ + return tensor if pos is None else tensor + pos + + def forward_post(self, tgt): + """Apply FFN with post-normalization. + + Args: + tgt: Target tensor + + Returns: + Output tensor after FFN + """ + tgt2 = self.linear2(self.dropout(self.activation(self.linear1(tgt)))) + tgt = tgt + self.dropout(tgt2) + tgt = self.norm(tgt) + return tgt + + def forward_pre(self, tgt): + """Apply FFN with pre-normalization. + + Args: + tgt: Target tensor + + Returns: + Output tensor after FFN + """ + if self.ffn_type == "standard": + tgt2 = self.norm(tgt) + tgt2 = self.linear2(self.dropout(self.activation(self.linear1(tgt2)))) + tgt = tgt + self.dropout(tgt2) + return tgt + elif self.ffn_type == "convnext": + # tgt shape is -> QxNxC (Q: num. of query - N: batch size - C: num. of channels) + Q, N, C = tgt.shape + H = W = int(Q**0.5) + x = tgt.permute(1, 2, 0).reshape(N, C, H, W) + + tgt2 = self.dwconv(x) + tgt2 = tgt2.permute(0, 2, 3, 1) # (N, C, H, W) -> (N, H, W, C) + tgt2 = self.norm(tgt2) + tgt2 = self.pwconv1(tgt2) + tgt2 = self.act(tgt2) + tgt2 = self.pwconv2(tgt2) + if self.gamma is not None: + tgt2 = self.gamma * tgt2 + tgt2 = tgt2.permute(0, 3, 1, 2) # (N, H, W, C) -> (N, C, H, W) + + tgt = tgt + self.drop_path(torch.flatten(tgt2, 2, 3).permute(2, 0, 1)) + return tgt + + def forward(self, tgt): + """Apply FFN based on normalization preference. + + Args: + tgt: Target tensor + + Returns: + Output tensor after FFN + """ + if self.normalize_before: + return self.forward_pre(tgt) + return self.forward_post(tgt) + + +class Transformer(nn.Module): + def __init__( + self, + d_model=512, + nhead=8, + num_encoder_layers=6, + num_decoder_layers=6, + dim_feedforward=2048, + dropout=0.1, + activation="relu", + normalize_before=False, + return_intermediate_dec=False, + ): + super().__init__() + + encoder_layer = TransformerEncoderLayer(d_model, nhead, dim_feedforward, dropout, activation, normalize_before) + encoder_norm = nn.LayerNorm(d_model) if normalize_before else None + self.encoder = TransformerEncoder(encoder_layer, num_encoder_layers, encoder_norm) + + decoder_layer = TransformerDecoderLayer(d_model, nhead, dim_feedforward, dropout, activation, normalize_before) + decoder_norm = nn.LayerNorm(d_model) + self.decoder = TransformerDecoder( + decoder_layer, + num_decoder_layers, + decoder_norm, + return_intermediate=return_intermediate_dec, + ) + + self._reset_parameters() + + self.d_model = d_model + self.nhead = nhead + + def _reset_parameters(self): + for p in self.parameters(): + if p.dim() > 1: + nn.init.xavier_uniform_(p) + + def forward(self, src, mask, query_embed, pos_embed): + # flatten NxCxHxW to HWxNxC + bs, c, h, w = src.shape + src = src.flatten(2).permute(2, 0, 1) + pos_embed = pos_embed.flatten(2).permute(2, 0, 1) + query_embed = query_embed.unsqueeze(1).repeat(1, bs, 1) + if mask is not None: + mask = mask.flatten(1) + + tgt = torch.zeros_like(query_embed) + memory = self.encoder(src, src_key_padding_mask=mask, pos=pos_embed) + hs = self.decoder( + tgt, + memory, + memory_key_padding_mask=mask, + pos=pos_embed, + query_pos=query_embed, + ) + return hs.transpose(1, 2), memory.permute(1, 2, 0).view(bs, c, h, w) + + +class TransformerEncoder(nn.Module): + def __init__(self, encoder_layer, num_layers, norm=None): + super().__init__() + self.layers = _get_clones(encoder_layer, num_layers) + self.num_layers = num_layers + self.norm = norm + + def forward( + self, + src, + mask: Optional[Tensor] = None, + src_key_padding_mask: Optional[Tensor] = None, + pos_embed: Optional[Tensor] = None, + ): + output = src + + for layer in self.layers: + output = layer( + output, + src_mask=mask, + src_key_padding_mask=src_key_padding_mask, + pos_embed=pos_embed, + ) + + if self.norm is not None: + output = self.norm(output) + + return output + + +class TransformerDecoder(nn.Module): + def __init__(self, decoder_layer, num_layers, norm=None, return_intermediate=False): + super().__init__() + self.layers = _get_clones(decoder_layer, num_layers) + self.num_layers = num_layers + self.norm = norm + self.return_intermediate = return_intermediate + + def forward( + self, + tgt, + memory, + tgt_mask: Optional[Tensor] = None, + memory_mask: Optional[Tensor] = None, + tgt_key_padding_mask: Optional[Tensor] = None, + memory_key_padding_mask: Optional[Tensor] = None, + pos: Optional[Tensor] = None, + query_pos: Optional[Tensor] = None, + ): + output = tgt + + intermediate = [] + + for layer in self.layers: + output = layer( + output, + memory, + tgt_mask=tgt_mask, + memory_mask=memory_mask, + tgt_key_padding_mask=tgt_key_padding_mask, + memory_key_padding_mask=memory_key_padding_mask, + pos=pos, + query_pos=query_pos, + ) + if self.return_intermediate: + if self.norm is not None: + output = self.norm(output) + intermediate.append(output) + + if self.norm is not None: + output = self.norm(output) + if self.return_intermediate: + intermediate.pop() + intermediate.append(output) + + if self.return_intermediate: + return torch.stack(intermediate) + + return output.unsqueeze(0) + + +# transformer +class TransformerEncoderLayer(nn.Module): + def __init__( + self, + d_model, + nhead, + dim_feedforward=2048, + dropout=0.1, + activation="relu", + normalize_before=False, + batch_first=True, + ): + super().__init__() + self.normalize_before = normalize_before + + self.self_attn = nn.MultiheadAttention(d_model, nhead, dropout, batch_first=batch_first) + + self.linear1 = nn.Linear(d_model, dim_feedforward) + self.dropout = nn.Dropout(dropout) + self.linear2 = nn.Linear(dim_feedforward, d_model) + + self.norm1 = nn.LayerNorm(d_model) + self.norm2 = nn.LayerNorm(d_model) + self.dropout1 = nn.Dropout(dropout) + self.dropout2 = nn.Dropout(dropout) + self.activation = _get_activation_fn(activation) + + @staticmethod + def with_pos_embed(tensor, pos_embed): + return tensor if pos_embed is None else tensor + pos_embed + + def forward(self, src, src_mask=None, src_key_padding_mask=None, pos_embed=None) -> torch.Tensor: + residual = src + if self.normalize_before: + src = self.norm1(src) + q = k = self.with_pos_embed(src, pos_embed) + src = self.self_attn(q, k, value=src, attn_mask=src_mask, key_padding_mask=src_key_padding_mask)[0] + + src = residual + self.dropout1(src) + if not self.normalize_before: + src = self.norm1(src) + + residual = src + if self.normalize_before: + src = self.norm2(src) + src = self.linear2(self.dropout(self.activation(self.linear1(src)))) + src = residual + self.dropout2(src) + if not self.normalize_before: + src = self.norm2(src) + return src + + +class TransformerDecoderLayer(nn.Module): + def __init__( + self, + d_model, + nhead, + dim_feedforward=2048, + dropout=0.1, + activation="relu", + normalize_before=False, + ): + super().__init__() + self.self_attn = nn.MultiheadAttention(d_model, nhead, dropout=dropout) + self.multihead_attn = nn.MultiheadAttention(d_model, nhead, dropout=dropout) + # Implementation of Feedforward model + self.linear1 = nn.Linear(d_model, dim_feedforward) + self.dropout = nn.Dropout(dropout) + self.linear2 = nn.Linear(dim_feedforward, d_model) + + self.norm1 = nn.LayerNorm(d_model) + self.norm2 = nn.LayerNorm(d_model) + self.norm3 = nn.LayerNorm(d_model) + self.dropout1 = nn.Dropout(dropout) + self.dropout2 = nn.Dropout(dropout) + self.dropout3 = nn.Dropout(dropout) + + self.activation = _get_activation_fn(activation) + self.normalize_before = normalize_before + + def with_pos_embed(self, tensor, pos: Optional[Tensor]): + return tensor if pos is None else tensor + pos + + def forward_post( + self, + tgt, + memory, + tgt_mask: Optional[Tensor] = None, + memory_mask: Optional[Tensor] = None, + tgt_key_padding_mask: Optional[Tensor] = None, + memory_key_padding_mask: Optional[Tensor] = None, + pos: Optional[Tensor] = None, + query_pos: Optional[Tensor] = None, + ): + q = k = self.with_pos_embed(tgt, query_pos) + tgt2 = self.self_attn(q, k, value=tgt, attn_mask=tgt_mask, key_padding_mask=tgt_key_padding_mask)[0] + tgt = tgt + self.dropout1(tgt2) + tgt = self.norm1(tgt) + tgt2 = self.multihead_attn( + query=self.with_pos_embed(tgt, query_pos), + key=self.with_pos_embed(memory, pos), + value=memory, + attn_mask=memory_mask, + key_padding_mask=memory_key_padding_mask, + )[0] + tgt = tgt + self.dropout2(tgt2) + tgt = self.norm2(tgt) + tgt2 = self.linear2(self.dropout(self.activation(self.linear1(tgt)))) + tgt = tgt + self.dropout3(tgt2) + tgt = self.norm3(tgt) + return tgt + + def forward_pre( + self, + tgt, + memory, + tgt_mask: Optional[Tensor] = None, + memory_mask: Optional[Tensor] = None, + tgt_key_padding_mask: Optional[Tensor] = None, + memory_key_padding_mask: Optional[Tensor] = None, + pos: Optional[Tensor] = None, + query_pos: Optional[Tensor] = None, + ): + tgt2 = self.norm1(tgt) + q = k = self.with_pos_embed(tgt2, query_pos) + tgt2 = self.self_attn(q, k, value=tgt2, attn_mask=tgt_mask, key_padding_mask=tgt_key_padding_mask)[0] + tgt = tgt + self.dropout1(tgt2) + tgt2 = self.norm2(tgt) + tgt2 = self.multihead_attn( + query=self.with_pos_embed(tgt2, query_pos), + key=self.with_pos_embed(memory, pos), + value=memory, + attn_mask=memory_mask, + key_padding_mask=memory_key_padding_mask, + )[0] + tgt = tgt + self.dropout2(tgt2) + tgt2 = self.norm3(tgt) + tgt2 = self.linear2(self.dropout(self.activation(self.linear1(tgt2)))) + tgt = tgt + self.dropout3(tgt2) + return tgt + + def forward( + self, + tgt, + memory, + tgt_mask: Optional[Tensor] = None, + memory_mask: Optional[Tensor] = None, + tgt_key_padding_mask: Optional[Tensor] = None, + memory_key_padding_mask: Optional[Tensor] = None, + pos: Optional[Tensor] = None, + query_pos: Optional[Tensor] = None, + ): + if self.normalize_before: + return self.forward_pre( + tgt, + memory, + tgt_mask, + memory_mask, + tgt_key_padding_mask, + memory_key_padding_mask, + pos, + query_pos, + ) + return self.forward_post( + tgt, + memory, + tgt_mask, + memory_mask, + tgt_key_padding_mask, + memory_key_padding_mask, + pos, + query_pos, + ) + + +def _get_clones(module, N): + return nn.ModuleList([copy.deepcopy(module) for i in range(N)]) diff --git a/focoos/ports.py b/focoos/ports.py index b894a310..b1015f5d 100644 --- a/focoos/ports.py +++ b/focoos/ports.py @@ -1,20 +1,31 @@ +import inspect import json -import re -from dataclasses import dataclass +import os +from abc import ABC +from collections import OrderedDict +from dataclasses import asdict, dataclass, field, fields from datetime import datetime from enum import Enum -from typing import Annotated, Literal, Optional, Union +from pathlib import Path +from typing import Any, List, Literal, Optional, Tuple, Union -from pydantic import BaseModel, Field, field_validator +from pydantic import BaseModel +from torch import Tensor -S3_URL_REGEX = re.compile(r"^s3://" r"(?P[a-zA-Z0-9.-]+)/" r"(?P.+(\.tar\.gz|\.zip)?)$") +from focoos.structures import Instances DEV_API_URL = "https://api.dev.focoos.ai/v0" PROD_API_URL = "https://api.focoos.ai/v0" LOCAL_API_URL = "http://localhost:8501/v0" -class FocoosBaseModel(BaseModel): +ROOT_DIR = Path.home() / "FocoosAI" +ROOT_DIR = str(ROOT_DIR) if os.name == "nt" else ROOT_DIR +MODELS_DIR = os.path.join(ROOT_DIR, "models") +DATASETS_DIR = os.path.join(ROOT_DIR, "datasets") + + +class PydanticBase(BaseModel, ABC): @classmethod def from_json(cls, data: Union[str, dict]): if isinstance(data, str): @@ -113,145 +124,34 @@ class DatasetLayout(str, Enum): ROBOFLOW_SEG = "roboflow_seg" CATALOG = "catalog" SUPERVISELY = "supervisely" + CLS_FOLDER = "cls_folder" -class FocoosTask(str, Enum): +class Task(str, Enum): """Types of computer vision tasks supported by Focoos. Values: - DETECTION: Object detection - SEMSEG: Semantic segmentation - INSTANCE_SEGMENTATION: Instance segmentation + - CLASSIFICATION: Image classification """ DETECTION = "detection" SEMSEG = "semseg" INSTANCE_SEGMENTATION = "instseg" + CLASSIFICATION = "classification" -class Hyperparameters(FocoosBaseModel): - """Model training hyperparameters configuration. - - Attributes: - batch_size (int): Number of images processed in each training iteration. Range: 1-32. - Larger batch sizes require more GPU memory but can speed up training. - - eval_period (int): Number of iterations between model evaluations. Range: 50-2000. - Controls how frequently validation is performed during training. - - max_iters (int): Maximum number of training iterations. Range: 100-100,000. - Total number of times the model will see batches of training data. - - resolution (int): Input image resolution for the model. Range: 128-6400 pixels. - Higher resolutions can improve accuracy but require more compute. - - wandb_project (Optional[str]): Weights & Biases project name in format "ORG_ID/PROJECT_NAME". - Used for experiment tracking and visualization. - - wandb_apikey (Optional[str]): API key for Weights & Biases integration. - Required if using wandb_project. - - learning_rate (float): Step size for model weight updates. Range: 0.00001-0.1. - Controls how quickly the model learns. Too high can cause instability. - - decoder_multiplier (float): Multiplier for decoder learning rate. - Allows different learning rates for decoder vs backbone. - - backbone_multiplier (float): Multiplier for backbone learning rate. - Default 0.1 means backbone learns 10x slower than decoder. - - amp_enabled (bool): Whether to use automatic mixed precision training. - Can speed up training and reduce memory usage with minimal accuracy impact. - - weight_decay (float): L2 regularization factor to prevent overfitting. - Higher values = stronger regularization. - - ema_enabled (bool): Whether to use Exponential Moving Average of model weights. - Can improve model stability and final performance. - - ema_decay (float): Decay rate for EMA. Higher = slower but more stable updates. - Only used if ema_enabled=True. - - ema_warmup (int): Number of iterations before starting EMA. - Only used if ema_enabled=True. - - freeze_bn (bool): Whether to freeze all batch normalization layers. - Useful for fine-tuning with small batch sizes. - - freeze_bn_bkb (bool): Whether to freeze backbone batch normalization layers. - Default True to preserve pretrained backbone statistics. - - optimizer (str): Optimization algorithm. Options: "ADAMW", "SGD", "RMSPROP". - ADAMW generally works best for vision tasks. - - scheduler (str): Learning rate schedule. Options: "POLY", "FIXED", "COSINE", "MULTISTEP". - Controls how learning rate changes during training. - - early_stop (bool): Whether to stop training early if validation metrics plateau. - Can prevent overfitting and save compute time. - - patience (int): Number of evaluations to wait for improvement before early stopping. - Only used if early_stop=True. - - - """ - - batch_size: Annotated[ - int, - Field( - ge=1, - le=32, - description="Batch size, how many images are processed at every iteration", - ), - ] = 16 - eval_period: Annotated[ - int, - Field(ge=50, le=2000, description="How often iterations to evaluate the model"), - ] = 500 - max_iters: Annotated[ - int, - Field(1500, ge=100, le=100000, description="Maximum number of training iterations"), - ] = 1500 - resolution: Annotated[int, Field(640, description="Model expected resolution", ge=128, le=6400)] = 640 - wandb_project: Annotated[ - Optional[str], - Field(description="Wandb project name must be like ORG_ID/PROJECT_NAME"), - ] = None - wandb_apikey: Annotated[Optional[str], Field(description="Wandb API key")] = None - learning_rate: Annotated[ - float, - Field(gt=0.00001, lt=0.1, description="Learning rate"), - ] = 5e-4 - decoder_multiplier: Annotated[float, Field(description="Backbone multiplier")] = 1 - backbone_multiplier: float = 0.1 - amp_enabled: Annotated[bool, Field(description="Enable automatic mixed precision")] = True - weight_decay: Annotated[float, Field(description="Weight decay")] = 0.02 - ema_enabled: Annotated[bool, Field(description="Enable EMA (exponential moving average)")] = False - ema_decay: Annotated[float, Field(description="EMA decay rate")] = 0.999 - ema_warmup: Annotated[int, Field(description="EMA warmup")] = 100 - freeze_bn: Annotated[bool, Field(description="Freeze batch normalization layers")] = False - freeze_bn_bkb: Annotated[bool, Field(description="Freeze backbone batch normalization layers")] = True - optimizer: Literal["ADAMW", "SGD", "RMSPROP"] = "ADAMW" - scheduler: Literal["POLY", "FIXED", "COSINE", "MULTISTEP"] = "MULTISTEP" - - early_stop: Annotated[bool, Field(description="Enable early stopping")] = True - patience: Annotated[ - int, - Field( - description="(Only with early_stop=True) Validation cycles after which the train is stopped if there's no improvement in accuracy." - ), - ] = 5 - - @field_validator("wandb_project") - def validate_wandb_project(cls, value): - if value is not None: - # Define a regex pattern to match valid characters - if not re.match(r"^[\w.-/]+$", value): - raise ValueError("Wandb project name must only contain characters, dashes, underscores, and dots.") - return value +@dataclass +class StatusTransition: + status: ModelStatus + timestamp: str + detail: Optional[str] = None -class TrainingInfo(FocoosBaseModel): +@dataclass +class TrainingInfo: """Information about a model's training process. This class contains details about the training job configuration, status, and timing. @@ -271,21 +171,19 @@ class TrainingInfo(FocoosBaseModel): artifact_location: Storage location of the training artifacts and model outputs. """ - algorithm_name: str + algorithm_name: Optional[str] = "" # todo: remove + instance_device: Optional[str] = None instance_type: Optional[str] = None - volume_size: Optional[int] = 100 - max_runtime_in_seconds: Optional[int] = 36000 + volume_size: Optional[int] = None main_status: Optional[str] = None - secondary_status: Optional[str] = None failure_reason: Optional[str] = None - elapsed_time: Optional[int] = None - status_transitions: list[dict] = [] - start_time: Optional[datetime] = None - end_time: Optional[datetime] = None + status_transitions: Optional[list[dict]] = None + start_time: Optional[str] = None + end_time: Optional[str] = None artifact_location: Optional[str] = None -class ModelPreview(FocoosBaseModel): +class ModelPreview(PydanticBase): """Preview information for a Focoos model. This class provides a lightweight preview of model information in the Focoos platform, @@ -302,13 +200,13 @@ class ModelPreview(FocoosBaseModel): ref: str name: str - task: FocoosTask + task: Task description: Optional[str] = None status: ModelStatus focoos_model: str -class DatasetSpec(FocoosBaseModel): +class DatasetSpec(PydanticBase): """Specification details for a dataset in the Focoos platform. This class provides information about the dataset's size and composition, @@ -325,7 +223,7 @@ class DatasetSpec(FocoosBaseModel): size_mb: float -class DatasetPreview(FocoosBaseModel): +class DatasetPreview(PydanticBase): """Preview information for a Focoos dataset. This class provides metadata about a dataset in the Focoos platform, @@ -342,13 +240,13 @@ class DatasetPreview(FocoosBaseModel): ref: str name: str - task: FocoosTask + task: Task layout: DatasetLayout description: Optional[str] = None spec: Optional[DatasetSpec] = None -class ModelMetadata(FocoosBaseModel): +class RemoteModelInfo(PydanticBase): """Complete metadata for a Focoos model. This class contains comprehensive information about a model in the Focoos platform, @@ -368,7 +266,6 @@ class ModelMetadata(FocoosBaseModel): latencies (Optional[list[dict]]): Inference latency measurements across different configurations. classes (Optional[list[str]]): List of class names the model can detect or segment. im_size (Optional[int]): Input image size the model expects. - hyperparameters (Optional[Hyperparameters]): Training hyperparameters used. training_info (Optional[TrainingInfo]): Information about the training process. location (Optional[str]): Storage location of the model. dataset (Optional[DatasetPreview]): Information about the dataset used for training. @@ -377,33 +274,25 @@ class ModelMetadata(FocoosBaseModel): ref: str name: str description: Optional[str] = None + is_managed: bool owner_ref: str focoos_model: str - task: FocoosTask + config: Optional[dict] = None + task: Task created_at: datetime updated_at: datetime status: ModelStatus + model_family: Optional[str] = None metrics: Optional[dict] = None - latencies: Optional[list[dict]] = None classes: Optional[list[str]] = None im_size: Optional[int] = None - hyperparameters: Optional[Hyperparameters] = None training_info: Optional[TrainingInfo] = None - location: Optional[str] = None dataset: Optional[DatasetPreview] = None + hyperparameters: Optional[dict] = None + focoos_version: Optional[str] = None -class TrainInstance(str, Enum): - """Available training instance types. - - Values: - - ML_G4DN_XLARGE: ml.g4dn.xlarge instance, Nvidia Tesla T4, 16GB RAM, 4vCPU - """ - - ML_G4DN_XLARGE = "ml.g4dn.xlarge" - - -class FocoosDet(FocoosBaseModel): +class FocoosDet(PydanticBase): """Single detection result from a model. This class represents a single detection or segmentation result from a Focoos model. @@ -448,7 +337,7 @@ def from_json(cls, data: Union[str, dict]): return cls.model_validate(data_dict) -class FocoosDetections(FocoosBaseModel): +class FocoosDetections(PydanticBase): """Collection of detection results from a model. This class represents a collection of detection or segmentation results from a Focoos model. @@ -466,6 +355,9 @@ class FocoosDetections(FocoosBaseModel): detections: list[FocoosDet] latency: Optional[dict] = None + def __len__(self): + return len(self.detections) + @dataclass class OnnxRuntimeOpts: @@ -539,7 +431,20 @@ class LatencyMetrics: device: str -class RuntimeTypes(str, Enum): +class ExportFormat(str, Enum): + """Available export formats for model inference. + + Values: + - ONNX: ONNX format + - TORCHSCRIPT: TorchScript format + + """ + + ONNX = "onnx" + TORCHSCRIPT = "torchscript" + + +class RuntimeType(str, Enum): """Available runtime configurations for model inference. Values: @@ -559,36 +464,43 @@ class RuntimeTypes(str, Enum): ONNX_COREML = "onnx_coreml" TORCHSCRIPT_32 = "torchscript_32" + def to_export_format(self) -> ExportFormat: + if self == RuntimeType.TORCHSCRIPT_32: + return ExportFormat.TORCHSCRIPT + else: + return ExportFormat.ONNX + -class ModelFormat(str, Enum): - """Supported model formats. +class ModelExtension(str, Enum): + """Supported model extension. Values: - ONNX: ONNX format - TORCHSCRIPT: TorchScript format - + - WEIGHTS: Weights format """ ONNX = "onnx" TORCHSCRIPT = "pt" + WEIGHTS = "pth" @classmethod - def from_runtime_type(cls, runtime_type: RuntimeTypes): + def from_runtime_type(cls, runtime_type: RuntimeType): if runtime_type in [ - RuntimeTypes.ONNX_CUDA32, - RuntimeTypes.ONNX_TRT32, - RuntimeTypes.ONNX_TRT16, - RuntimeTypes.ONNX_CPU, - RuntimeTypes.ONNX_COREML, + RuntimeType.ONNX_CUDA32, + RuntimeType.ONNX_TRT32, + RuntimeType.ONNX_TRT16, + RuntimeType.ONNX_CPU, + RuntimeType.ONNX_COREML, ]: return cls.ONNX - elif runtime_type == RuntimeTypes.TORCHSCRIPT_32: + elif runtime_type == RuntimeType.TORCHSCRIPT_32: return cls.TORCHSCRIPT else: raise ValueError(f"Invalid runtime type: {runtime_type}") -class GPUDevice(FocoosBaseModel): +class GPUDevice(PydanticBase): """Information about a GPU device.""" gpu_id: Optional[int] = None @@ -599,71 +511,127 @@ class GPUDevice(FocoosBaseModel): gpu_load_percentage: Optional[float] = None -class GPUInfo(FocoosBaseModel): +class GPUInfo(PydanticBase): """Information about a GPU driver.""" gpu_count: Optional[int] = None gpu_driver: Optional[str] = None gpu_cuda_version: Optional[str] = None + total_gpu_memory_gb: Optional[float] = None devices: Optional[list[GPUDevice]] = None -class SystemInfo(FocoosBaseModel): +class SystemInfo(PydanticBase): """System information including hardware and software details.""" focoos_host: Optional[str] = None + focoos_version: Optional[str] = None + python_version: Optional[str] = None system: Optional[str] = None system_name: Optional[str] = None cpu_type: Optional[str] = None cpu_cores: Optional[int] = None memory_gb: Optional[float] = None memory_used_percentage: Optional[float] = None - available_providers: Optional[list[str]] = None + available_onnx_providers: Optional[list[str]] = None disk_space_total_gb: Optional[float] = None disk_space_used_percentage: Optional[float] = None + pytorch_info: Optional[str] = None gpu_info: Optional[GPUInfo] = None packages_versions: Optional[dict[str, str]] = None environment: Optional[dict[str, str]] = None - def pretty_print(self): - print("================ SYSTEM INFO ====================") - for key, value in self.model_dump().items(): + def pprint(self, level: Literal["INFO", "DEBUG"] = "DEBUG"): + """Pretty print the system info.""" + from focoos.utils.logger import get_logger + + logger = get_logger("SystemInfo", level=level) + + output_lines = ["\n================ ๐Ÿ” SYSTEM INFO ๐Ÿ” ===================="] + model_data = self.model_dump() + + if "focoos_host" in model_data and "focoos_version" in model_data: + output_lines.append(f"focoos: {model_data.get('focoos_host')} (v{model_data.get('focoos_version')})") + model_data.pop("focoos_host", None) + model_data.pop("focoos_version", None) + + if "system" in model_data and "system_name" in model_data: + output_lines.append(f"system: {model_data.get('system')} ({model_data.get('system_name')})") + model_data.pop("system", None) + model_data.pop("system_name", None) + + if "cpu_type" in model_data and "cpu_cores" in model_data: + output_lines.append(f"cpu: {model_data.get('cpu_type')} ({model_data.get('cpu_cores')} cores)") + model_data.pop("cpu_type", None) + model_data.pop("cpu_cores", None) + + if "memory_gb" in model_data and "memory_used_percentage" in model_data: + output_lines.append( + f"memory_gb: {model_data.get('memory_gb')} ({model_data.get('memory_used_percentage')}% used)" + ) + model_data.pop("memory_gb", None) + model_data.pop("memory_used_percentage", None) + + if "disk_space_total_gb" in model_data and "disk_space_used_percentage" in model_data: + output_lines.append( + f"disk_space_total_gb: {model_data.get('disk_space_total_gb')} ({model_data.get('disk_space_used_percentage')}% used)" + ) + model_data.pop("disk_space_total_gb", None) + model_data.pop("disk_space_used_percentage", None) + + for key, value in model_data.items(): if key == "gpu_info" and value is not None: - print(f"{key}:") - print(f" - gpu_count: {value.get('gpu_count')}") - print(f" - gpu_driver: {value.get('gpu_driver')}") - print(f" - gpu_cuda_version: {value.get('gpu_cuda_version')}") + output_lines.append(f"{key}:") + output_lines.append(f" - gpu_count: {value.get('gpu_count')}") + output_lines.append(f" - total_memory_gb: {value.get('total_gpu_memory_gb')} GB") + output_lines.append(f" - gpu_driver: {value.get('gpu_driver')}") + output_lines.append(f" - gpu_cuda_version: {value.get('gpu_cuda_version')}") if value.get("devices"): - print(" - devices:") + output_lines.append(" - devices:") for device in value.get("devices", []): - print(f" - GPU {device.get('gpu_id')}:") - for device_key, device_value in device.items(): - if device_key != "gpu_id": - print(f" - {device_key}: {device_value}") + gpu_memory_used = ( + f"{device.get('gpu_memory_used_percentage')}%" + if device.get("gpu_memory_used_percentage") is not None + else "N/A" + ) + gpu_load = ( + f"{device.get('gpu_load_percentage')}%" + if device.get("gpu_load_percentage") is not None + else "N/A" + ) + gpu_memory_total = ( + f"{device.get('gpu_memory_total_gb')} GB" + if device.get("gpu_memory_total_gb") is not None + else "N/A" + ) + + output_lines.append( + f" - GPU {device.get('gpu_id')}: {device.get('gpu_name')}, Memory: {gpu_memory_total} ({gpu_memory_used} used), Load: {gpu_load}" + ) elif isinstance(value, list): - print(f"{key}:") - for item in value: - print(f" - {item}") + output_lines.append(f"{key}: {value}") elif isinstance(value, dict) and key == "packages_versions": # Special formatting for packages_versions - print(f"{key}:") + output_lines.append(f"{key}:") for pkg_name, pkg_version in value.items(): - print(f" - {pkg_name}: {pkg_version}") + output_lines.append(f" - {pkg_name}: {pkg_version}") elif isinstance(value, dict) and key == "environment": # Special formatting for environment - print(f"{key}:") + output_lines.append(f"{key}:") for env_key, env_value in value.items(): - print(f" - {env_key}: {env_value}") + output_lines.append(f" - {env_key}: {env_value}") else: - print(f"{key}: {value}") - print("================================================") + output_lines.append(f"{key}: {value}") + output_lines.append("================================================") + + logger.info("\n".join(output_lines)) -class ApiKey(FocoosBaseModel): +class ApiKey(PydanticBase): """API key for authentication.""" key: str # type: ignore -class Quotas(FocoosBaseModel): +class Quotas(PydanticBase): """Usage quotas and limits for a user account. Attributes: @@ -690,7 +658,7 @@ class Quotas(FocoosBaseModel): max_mlg4dnxlarge_training_jobs_hours: float -class User(FocoosBaseModel): +class User(PydanticBase): """User account information. This class represents a user account in the Focoos platform, containing @@ -721,14 +689,509 @@ def __init__(self, message: str): super().__init__(self.message) -class Metrics(FocoosBaseModel): +@dataclass +class Metrics: """ Collection of training and inference metrics. """ - infer_metrics: list[dict] = [] - valid_metrics: list[dict] = [] - train_metrics: list[dict] = [] + infer_metrics: list[dict] = field(default_factory=list) + valid_metrics: list[dict] = field(default_factory=list) + train_metrics: list[dict] = field(default_factory=list) iterations: Optional[int] = None best_valid_metric: Optional[dict] = None - updated_at: Optional[datetime] = None + + +class ModelFamily(str, Enum): + """Enumerazione delle famiglie di modelli disponibili""" + + DETR = "fai_detr" + MASKFORMER = "fai_mf" + BISENETFORMER = "bisenetformer" + IMAGE_CLASSIFIER = "fai_cls" + + +# This should not be a dataclass, but their child must be +class DictClass(OrderedDict): + def to_tuple(self) -> tuple[Any]: + """ + Convert self to a tuple containing all the attributes/keys that are not `None`. + """ + return tuple( + self[k] for k in self.keys() if self[k] is not None + ) # without this check we are unable to export models with None values + + def __getitem__(self, k): + if isinstance(k, str): + inner_dict = dict(self.items()) + return inner_dict[k] + else: + return self.to_tuple()[k] + + def __setattr__(self, name, value): + if name in self.keys() and value is not None: + # Don't call self.__setitem__ to avoid recursion errors + super().__setitem__(name, value) + super().__setattr__(name, value) + + def __setitem__(self, key, value): + # Will raise a KeyException if needed + super().__setitem__(key, value) + # Don't call self.__setattr__ to avoid recursion errors + super().__setattr__(key, value) + + def __post_init__(self): + """Check the BasicContainer dataclass. + + Only occurs if @dataclass decorator has been used. + """ + class_fields = fields(self) + + # Safety and consistency checks + if not len(class_fields): + raise ValueError(f"{self.__class__.__name__} has no fields.") + + for _field in class_fields: + v = getattr(self, _field.name) + # if v is not None: # without this check we are unable to export models with None values + # self[_field.name] = v + self[_field.name] = v + + def __reduce__(self): + state_dict = {field.name: getattr(self, field.name) for field in fields(self)} + return (self.__class__.__new__, (self.__class__,), state_dict) + + +@dataclass +class ModelConfig(DictClass): + num_classes: int + + +@dataclass +class ModelOutput(DictClass): + """Model output base container.""" + + loss: Optional[dict] + + +@dataclass +class DatasetEntry(DictClass): + image: Optional[Tensor] = None + height: Optional[int] = None + width: Optional[int] = None + instances: Optional[Instances] = None + file_name: Optional[str] = None + image_id: Optional[int] = None + + +class DatasetSplitType(str, Enum): + TRAIN = "train" + VAL = "val" + TEST = "test" + + +def get_gpus_count(): + try: + import torch.cuda + + return torch.cuda.device_count() + except ImportError: + return 0 + + +@dataclass +class TrainerArgs: + """Configuration class for unified model training. + + Attributes: + run_name (str): Name of the training run + output_dir (str): Directory to save outputs + ckpt_dir (Optional[str]): Directory for checkpoints + init_checkpoint (Optional[str]): Initial checkpoint to load + resume (bool): Whether to resume from checkpoint + num_gpus (int): Number of GPUs to use + device (str): Device to use (cuda/cpu) + workers (int): Number of data loading workers + amp_enabled (bool): Whether to use automatic mixed precision + ddp_broadcast_buffers (bool): Whether to broadcast buffers in DDP + ddp_find_unused (bool): Whether to find unused parameters in DDP + checkpointer_period (int): How often to save checkpoints + checkpointer_max_to_keep (int): Maximum checkpoints to keep + eval_period (int): How often to evaluate + log_period (int): How often to log + vis_period (int): How often to visualize + samples (int): Number of samples for visualization + seed (int): Random seed + early_stop (bool): Whether to use early stopping + patience (int): Early stopping patience + ema_enabled (bool): Whether to use EMA + ema_decay (float): EMA decay rate + ema_warmup (int): EMA warmup period + learning_rate (float): Base learning rate + weight_decay (float): Weight decay + max_iters (int): Maximum training iterations + batch_size (int): Batch size + scheduler (str): Learning rate scheduler type + scheduler_extra (Optional[dict]): Extra scheduler parameters + optimizer (str): Optimizer type + optimizer_extra (Optional[dict]): Extra optimizer parameters + weight_decay_norm (float): Weight decay for normalization layers + weight_decay_embed (float): Weight decay for embeddings + backbone_multiplier (float): Learning rate multiplier for backbone + decoder_multiplier (float): Learning rate multiplier for decoder + head_multiplier (float): Learning rate multiplier for head + freeze_bn (bool): Whether to freeze batch norm + clip_gradients (float): Gradient clipping value + size_divisibility (int): Input size divisibility requirement + gather_metric_period (int): How often to gather metrics + zero_grad_before_forward (bool): Whether to zero gradients before forward pass + """ + + run_name: str + output_dir: str = MODELS_DIR + ckpt_dir: Optional[str] = None + init_checkpoint: Optional[str] = None + resume: bool = False + # Logistics params + num_gpus: int = get_gpus_count() + device: str = "cuda" + workers: int = 4 + amp_enabled: bool = True + ddp_broadcast_buffers: bool = False + ddp_find_unused: bool = True + checkpointer_period: int = 1000 + checkpointer_max_to_keep: int = 1 + eval_period: int = 50 + log_period: int = 20 + samples: int = 9 + seed: int = 42 + early_stop: bool = True + patience: int = 10 + # EMA + ema_enabled: bool = False + ema_decay: float = 0.999 + ema_warmup: int = 2000 + # Hyperparameters + learning_rate: float = 5e-4 + weight_decay: float = 0.02 + max_iters: int = 3000 + batch_size: int = 16 + scheduler: Literal["POLY", "FIXED", "COSINE", "MULTISTEP"] = "MULTISTEP" + scheduler_extra: Optional[dict] = None + optimizer: Literal["ADAMW", "SGD", "RMSPROP"] = "ADAMW" + optimizer_extra: Optional[dict] = None + weight_decay_norm: float = 0.0 + weight_decay_embed: float = 0.0 + backbone_multiplier: float = 0.1 + decoder_multiplier: float = 1.0 + head_multiplier: float = 1.0 + freeze_bn: bool = False + clip_gradients: float = 0.1 + size_divisibility: int = 0 + # Training specific + gather_metric_period: int = 1 + zero_grad_before_forward: bool = False + + # Sync to hub + sync_to_hub: bool = False + + +@dataclass +class DatasetMetadata: + """Dataclass for storing dataset metadata.""" + + num_classes: int + task: Task + count: Optional[int] = None + name: Optional[str] = None + image_root: Optional[str] = None + thing_classes: Optional[List[str]] = None + _thing_colors: Optional[List[Tuple]] = None + stuff_classes: Optional[List[str]] = None + _stuff_colors: Optional[List[Tuple]] = None + sem_seg_root: Optional[str] = None + panoptic_root: Optional[str] = None + ignore_label: Optional[int] = None + thing_dataset_id_to_contiguous_id: Optional[dict] = None + stuff_dataset_id_to_contiguous_id: Optional[dict] = None + json_file: Optional[str] = None + + @property + def classes(self) -> List[str]: #!TODO: check if this is correct + if self.task == Task.DETECTION or self.task == Task.INSTANCE_SEGMENTATION: + assert self.thing_classes is not None, "thing_classes is required for detection and instance segmentation" + return self.thing_classes + if self.task == Task.SEMSEG: + # fixme: not sure for panoptic + assert self.stuff_classes is not None, "stuff_classes is required for semantic segmentation" + return self.stuff_classes + if self.task == Task.CLASSIFICATION: + assert self.thing_classes is not None, "thing_classes is required for classification" + return self.thing_classes + raise ValueError(f"Task {self.task} not supported") + + @property + def stuff_colors(self): + if self._stuff_colors is not None: + return self._stuff_colors + if self.stuff_classes is None: + return [] + return [((i * 64) % 255, (i * 128) % 255, (i * 32) % 255) for i in range(len(self.stuff_classes))] + + @stuff_colors.setter + def stuff_colors(self, colors): + self._stuff_colors = colors + + @property + def thing_colors(self): + if self._thing_colors is not None: + return self._thing_colors + if self.thing_classes is None: + return [] + return [((i * 64) % 255, (i * 128) % 255, (i * 32) % 255) for i in range(1, len(self.thing_classes) + 1)] + + @thing_colors.setter + def thing_colors(self, colors): + self._thing_colors = colors + + @classmethod + def from_dict(cls, metadata: dict): + """Create DatasetMetadata from a dictionary. + + Args: + metadata (dict): Dictionary containing metadata. + + Returns: + DatasetMetadata: Instance of DatasetMetadata. + """ + metadata = {k: v for k, v in metadata.items() if k in inspect.signature(cls).parameters} + metadata["task"] = Task(metadata["task"]) + return cls(**metadata) + + @classmethod + def from_json(cls, path: str): + """Create DatasetMetadata from a json file. + + Args: + path (str): Path to json file. + + Returns: + DatasetMetadata: Instance of DatasetMetadata. + """ + with open(path, encoding="utf-8") as f: + metadata = json.load(f) + metadata["task"] = Task(metadata["task"]) + return cls(**metadata) + + def dump_json(self, path: str): + """Dump DatasetMetadata to a json file. + + Args: + path (str): Path to json file. + """ + with open(path, "w", encoding="utf-8") as f: + json.dump(asdict(self), f, ensure_ascii=False, indent=4) + + def get(self, attr, default=None): + if hasattr(self, attr): + return getattr(self, attr) + else: + return default + + +@dataclass +class DetectronDict: + file_name: str + height: Optional[int] = None + width: Optional[int] = None + image_id: Optional[Union[str, int]] = None + sem_seg_file_name: Optional[str] = None + pan_seg_file_name: Optional[str] = None + annotations: Optional[list[dict]] = None + segments_info: Optional[list[dict]] = None + + +@dataclass +class ModelInfo(DictClass): + """ + Comprehensive metadata for a Focoos model. + + This dataclass encapsulates all relevant information required to identify, configure, and evaluate a model + within the Focoos platform. It is used for serialization, deserialization, and programmatic access to model + properties. + + Attributes: + name (str): Human-readable name or unique identifier for the model. + model_family (ModelFamily): The model's architecture family (e.g., RTDETR, M2F). + classes (list[str]): List of class names that the model can detect or segment. + im_size (int): Input image size (usually square, e.g., 640). + task (Task): Computer vision task performed by the model (e.g., detection, segmentation). + config (dict): Model-specific configuration parameters. + ref (Optional[str]): Optional unique reference string for the model. + focoos_model (Optional[str]): Optional Focoos base model identifier. + status (Optional[ModelStatus]): Current status of the model (e.g., training, ready). + description (Optional[str]): Optional human-readable description of the model. + train_args (Optional[TrainerArgs]): Optional training arguments used to train the model. + weights_uri (Optional[str]): Optional URI or path to the model weights. + val_dataset (Optional[str]): Optional name or reference of the validation dataset. + val_metrics (Optional[dict]): Optional dictionary of validation metrics (e.g., mAP, accuracy). + focoos_version (Optional[str]): Optional Focoos version string. + latency (Optional[list[LatencyMetrics]]): Optional list of latency measurements for different runtimes. + updated_at (Optional[str]): Optional ISO timestamp of the last update. + """ + + name: str + model_family: ModelFamily + classes: list[str] + im_size: int + task: Task + config: dict + ref: Optional[str] = None + focoos_model: Optional[str] = None + status: Optional[ModelStatus] = None + description: Optional[str] = None + train_args: Optional[TrainerArgs] = None + weights_uri: Optional[str] = None + val_dataset: Optional[str] = None + val_metrics: Optional[dict] = None # TODO: Consider making metrics explicit in the future + focoos_version: Optional[str] = None + latency: Optional[list[LatencyMetrics]] = None + training_info: Optional[TrainingInfo] = None + updated_at: Optional[str] = None + + @classmethod + def from_json(cls, data: Union[str, dict]): + """ + Load ModelInfo from a JSON file. + + Args: + path (Optional[str]): Path to the JSON file containing model metadata. + data (Optional[dict]): Dictionary containing model metadata. + + Returns: + ModelInfo: An instance of ModelInfo populated with data from the file. + """ + assert isinstance(data, dict) or isinstance(data, str), "data must be a dictionary or a path to a JSON file" + if isinstance(data, str): + with open(data, encoding="utf-8") as f: + model_info_json = json.load(f) + else: + model_info_json = data + + training_info = None + if "training_info" in model_info_json and model_info_json["training_info"] is not None: + training_info = TrainingInfo( + **{k: v for k, v in model_info_json["training_info"].items() if k in TrainingInfo.__dataclass_fields__} + ) + + model_info = cls( + name=model_info_json["name"], + ref=model_info_json.get("ref", None), + model_family=ModelFamily(model_info_json["model_family"]), + classes=model_info_json["classes"], + im_size=int(model_info_json["im_size"]), + status=ModelStatus(model_info_json.get("status")) if model_info_json.get("status") else None, + task=Task(model_info_json["task"]), + focoos_model=model_info_json.get("focoos_model", None), + config=model_info_json["config"], + description=model_info_json.get("description", None), + train_args=TrainerArgs( + **{k: v for k, v in model_info_json["train_args"].items() if k in TrainerArgs.__dataclass_fields__} + ) + if "train_args" in model_info_json and model_info_json["train_args"] is not None + else None, + weights_uri=model_info_json.get("weights_uri", None), + val_dataset=model_info_json.get("val_dataset", None), + latency=[LatencyMetrics(**latency) for latency in model_info_json.get("latency", [])] + if "latency" in model_info_json and model_info_json["latency"] is not None + else None, + updated_at=model_info_json.get("updated_at", None), + focoos_version=model_info_json.get("focoos_version", None), + val_metrics=model_info_json.get("val_metrics", None), + training_info=training_info, + ) + return model_info + + def dump_json(self, path: str): + """ + Serialize ModelInfo to a JSON file. + + Args: + path (str): Path where the JSON file will be saved. + """ + data = asdict(self) + # Note: config_class is not included; if needed, convert to string here. + + with open(path, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=4) + + def pprint(self): + """ + Pretty-print the main model information using the Focoos logger. + """ + from focoos.utils.logger import get_logger + + logger = get_logger("model_info") + logger.info( + f""" + ๐Ÿ“‹ Name: {self.name} + ๐Ÿ“ Description: {self.description} + ๐Ÿ‘ช Family: {self.model_family} + ๐Ÿ”— Focoos Model: {self.focoos_model} + ๐ŸŽฏ Task: {self.task} + ๐Ÿท๏ธ Classes: {self.classes} + ๐Ÿ–ผ๏ธ Im size: {self.im_size} + """ + ) + + +@dataclass +class ExportCfg: + """Configuration for model export. + + Args: + out_dir: Output directory for exported model + onnx_opset: ONNX opset version to use + onnx_dynamic: Whether to use dynamic axes in ONNX export + onnx_simplify: Whether to simplify ONNX model + model_fuse: Whether to fuse model layers + format: Export format ("onnx" or "torchscript") + device: Device to use for export + """ + + out_dir: str + onnx_opset: int = 17 + onnx_dynamic: bool = True + onnx_simplify: bool = True + model_fuse: bool = True + format: Literal["onnx", "torchscript"] = "onnx" + device: Optional[str] = "cuda" + + +@dataclass +class DynamicAxes: + """Dynamic axes for model export.""" + + input_names: list[str] + output_names: list[str] + dynamic_axes: dict + + +class ArtifactName(str, Enum): + """Model artifact type.""" + + WEIGHTS = "model_final.pth" + ONNX = "model.onnx" + PT = "model.pt" + INFO = "model_info.json" + METRICS = "metrics.json" + LOGS = "log.txt" + + +@dataclass +class HubSyncLocalTraining: + status: Optional[ModelStatus] = None + training_info: Optional[TrainingInfo] = None + metrics: Optional[Metrics] = None + iterations: Optional[int] = None + focoos_version: Optional[str] = None diff --git a/focoos/processor/__init__.py b/focoos/processor/__init__.py new file mode 100644 index 00000000..69597137 --- /dev/null +++ b/focoos/processor/__init__.py @@ -0,0 +1,3 @@ +from focoos.processor.processor_manager import ProcessorManager + +__all__ = ["ProcessorManager"] diff --git a/focoos/processor/base_processor.py b/focoos/processor/base_processor.py new file mode 100644 index 00000000..992188d3 --- /dev/null +++ b/focoos/processor/base_processor.py @@ -0,0 +1,271 @@ +from abc import ABC, abstractmethod +from typing import Any, Literal, Optional, Union + +import numpy as np +import torch +from PIL import Image + +from focoos.ports import DatasetEntry, DynamicAxes, FocoosDetections, ModelConfig, ModelOutput + + +class Processor(ABC): + """Abstract base class for model processors that handle preprocessing and postprocessing. + + This class defines the interface for processing inputs and outputs for different model types. + Subclasses must implement the abstract methods to provide model-specific processing logic. + + Attributes: + config (ModelConfig): Configuration object containing model-specific settings. + training (bool): Flag indicating whether the processor is in training mode. + """ + + def __init__(self, config: ModelConfig): + """Initialize the processor with the given configuration. + + Args: + config (ModelConfig): Model configuration containing settings and parameters. + """ + self.config = config + self.training = False + + def eval(self): + """Set the processor to evaluation mode. + + Returns: + Processor: Self reference for method chaining. + """ + self.training = False + return self + + def train(self, training: bool = True): + """Set the processor training mode. + + Args: + training (bool, optional): Whether to set training mode. Defaults to True. + + Returns: + Processor: Self reference for method chaining. + """ + self.training = training + return self + + @abstractmethod + def preprocess( + self, + inputs: Union[torch.Tensor, np.ndarray, Image.Image, list[Image.Image], list[np.ndarray], list[torch.Tensor]], + device: Union[Literal["cuda", "cpu"], torch.device] = "cuda", + dtype: torch.dtype = torch.float32, + image_size: Optional[int] = None, + ) -> tuple[torch.Tensor, Any]: + """Preprocess input data for model inference. + + This method must be implemented by subclasses to handle model-specific preprocessing + such as resizing, normalization, and tensor formatting. + + Args: + inputs: Input data which can be single or multiple images in various formats. + device: Target device for tensor placement. Defaults to "cuda". + dtype: Target data type for tensors. Defaults to torch.float32. + image_size: Optional target image size for resizing. Defaults to None. + + Returns: + tuple[torch.Tensor, Any]: Preprocessed tensor and any additional metadata. + + Raises: + NotImplementedError: If not implemented by subclass. + """ + raise NotImplementedError("Pre-processing is not implemented for this model.") + + @abstractmethod + def postprocess( + self, + outputs: ModelOutput, + inputs: Union[torch.Tensor, np.ndarray, Image.Image, list[Image.Image], list[np.ndarray], list[torch.Tensor]], + class_names: list[str] = [], + threshold: float = 0.5, + **kwargs, + ) -> list[FocoosDetections]: + """Postprocess model outputs to generate final detection results. + + This method must be implemented by subclasses to convert raw model outputs + into structured detection results. + + Args: + outputs (ModelOutput): Raw outputs from the model. + inputs: Original input data for reference during postprocessing. + class_names (list[str], optional): List of class names for detection labels. + Defaults to empty list. + threshold (float, optional): Confidence threshold for detections. Defaults to 0.5. + **kwargs: Additional keyword arguments for model-specific postprocessing. + + Returns: + list[FocoosDetections]: List of detection results for each input. + + Raises: + NotImplementedError: If not implemented by subclass. + """ + raise NotImplementedError("Post-processing is not implemented for this model.") + + @abstractmethod + def export_postprocess( + self, + output: Union[list[torch.Tensor], list[np.ndarray]], + inputs: Union[ + torch.Tensor, + np.ndarray, + Image.Image, + list[Image.Image], + list[np.ndarray], + list[torch.Tensor], + ], + threshold: Optional[float] = None, + **kwargs, + ) -> list[FocoosDetections]: + """Postprocess outputs from exported model for inference. + + This method handles postprocessing for models that have been exported + (e.g., to ONNX format) and may have different output formats. + + Args: + output: Raw outputs from exported model as tensors or numpy arrays. + inputs: Original input data for reference during postprocessing. + threshold: Optional confidence threshold for detections. Defaults to None. + **kwargs: Additional keyword arguments for export-specific postprocessing. + + Returns: + list[FocoosDetections]: List of detection results for each input. + + Raises: + NotImplementedError: If not implemented by subclass. + """ + raise NotImplementedError("Export post-processing is not implemented for this model.") + + @abstractmethod + def get_dynamic_axes(self) -> DynamicAxes: + """Get dynamic axes configuration for model export. + + This method defines which axes can vary in size during model export, + typically used for ONNX export with dynamic batch sizes or image dimensions. + + Returns: + DynamicAxes: Configuration specifying which axes are dynamic. + + Raises: + NotImplementedError: If not implemented by subclass. + """ + raise NotImplementedError("Export axes are not implemented for this model.") + + @abstractmethod + def eval_postprocess(self, outputs: ModelOutput, inputs: list[DatasetEntry]): + """Postprocess model outputs for evaluation purposes. + + This method handles postprocessing specifically for model evaluation, + which may differ from inference postprocessing. + + Args: + outputs (ModelOutput): Raw outputs from the model. + inputs (list[DatasetEntry]): List of dataset entries used as inputs. + + Raises: + NotImplementedError: If not implemented by subclass. + """ + raise NotImplementedError("Post-processing is not implemented for this model.") + + def get_image_sizes( + self, + inputs: Union[torch.Tensor, np.ndarray, Image.Image, list[Image.Image], list[np.ndarray], list[torch.Tensor]], + ): + """Extract image dimensions from various input formats. + + This utility method determines the height and width of images from different + input types including tensors, numpy arrays, and PIL images. + + Args: + inputs: Input data containing one or more images in various formats. + + Returns: + list[tuple[int, int]]: List of (height, width) tuples for each image. + + Raises: + ValueError: If input type is not supported. + """ + image_sizes = [] + + if isinstance(inputs, (torch.Tensor, np.ndarray)): + # Single tensor/array input + if isinstance(inputs, torch.Tensor): + height, width = inputs.shape[-2:] + else: # numpy array + height, width = inputs.shape[-3:-1] if inputs.ndim > 3 else inputs.shape[:2] + image_sizes.append((height, width)) + elif isinstance(inputs, Image.Image): + # Single PIL image + width, height = inputs.size + image_sizes.append((height, width)) + elif isinstance(inputs, list): + # List of inputs + for img in inputs: + if isinstance(img, torch.Tensor): + height, width = img.shape[-2:] + elif isinstance(img, np.ndarray): + height, width = img.shape[-3:-1] if img.ndim > 3 else img.shape[:2] + elif isinstance(img, Image.Image): + width, height = img.size + else: + raise ValueError(f"Unsupported input type in list: {type(img)}") + image_sizes.append((height, width)) + else: + raise ValueError(f"Unsupported input type: {type(inputs)}") + return image_sizes + + def get_tensors( + self, + inputs: Union[torch.Tensor, np.ndarray, Image.Image, list[Image.Image], list[np.ndarray], list[torch.Tensor]], + ) -> torch.Tensor: + """Convert various input formats to a batched PyTorch tensor. + + This utility method standardizes different input types (PIL Images, numpy arrays, + PyTorch tensors) into a single batched tensor with consistent format (BCHW). + + Args: + inputs: Input data containing one or more images in various formats. + + Returns: + torch.Tensor: Batched tensor with shape (B, C, H, W) where: + - B is batch size + - C is number of channels (typically 3 for RGB) + - H is height + - W is width + + Note: + This method may break with different image sizes as it uses torch.cat + which requires consistent dimensions across inputs. + """ + if isinstance(inputs, (Image.Image, np.ndarray, torch.Tensor)): + inputs_list = [inputs] + else: + inputs_list = inputs + + # Process each input based on its type + processed_inputs = [] + for inp in inputs_list: + # todo check for tensor of 4 dimesions. + if isinstance(inp, Image.Image): + inp = np.array(inp) + if isinstance(inp, np.ndarray): + inp = torch.from_numpy(inp) + + # Ensure input has correct shape and type + if inp.dim() == 3: # Add batch dimension if missing + inp = inp.unsqueeze(0) + if inp.shape[1] != 3 and inp.shape[-1] == 3: # Convert HWC to CHW if needed + inp = inp.permute(0, 3, 1, 2) + + processed_inputs.append(inp) + + # Stack all inputs into a single batch tensor + # use pixel mean to get dtype -> If fp16, pixel_mean is fp16, so inputs will be fp16 + # TODO: this will break with different image sizes + images_torch = torch.cat(processed_inputs, dim=0) + + return images_torch diff --git a/focoos/processor/processor_manager.py b/focoos/processor/processor_manager.py new file mode 100644 index 00000000..ce5f422a --- /dev/null +++ b/focoos/processor/processor_manager.py @@ -0,0 +1,40 @@ +import importlib +from typing import Callable, Dict, Type + +from focoos.ports import ModelConfig, ModelFamily +from focoos.processor.base_processor import Processor + + +class ProcessorManager: + """Automatic processor manager with lazy loading""" + + _PROCESSOR_MAPPING: Dict[str, Callable[[], Type[Processor]]] = {} + + @classmethod + def register_processor(cls, model_family: ModelFamily, processor_loader: Callable[[], Type[Processor]]): + """ + Register a loader for a specific processor + """ + cls._PROCESSOR_MAPPING[model_family.value] = processor_loader + + @classmethod + def _ensure_family_registered(cls, model_family: ModelFamily): + """Ensure the processor family is registered, importing if needed.""" + if model_family.value not in cls._PROCESSOR_MAPPING: + family_module = importlib.import_module(f"focoos.models.{model_family.value}") + for attr_name in dir(family_module): + if attr_name.startswith("_register"): + register_func = getattr(family_module, attr_name) + if callable(register_func): + register_func() + + @classmethod + def get_processor(cls, model_family: ModelFamily, model_config: ModelConfig) -> Processor: + """ + Get a processor instance for the given model family. + """ + cls._ensure_family_registered(model_family) + if model_family.value not in cls._PROCESSOR_MAPPING: + raise ValueError(f"Processor for {model_family} not supported") + processor_class = cls._PROCESSOR_MAPPING[model_family.value]() + return processor_class(config=model_config) diff --git a/focoos/runtime.py b/focoos/runtime.py deleted file mode 100644 index fc905752..00000000 --- a/focoos/runtime.py +++ /dev/null @@ -1,457 +0,0 @@ -""" -Runtime Module for the models - -This module provides the necessary functionality for loading, preprocessing, -running inference, and benchmarking ONNX and TorchScript models using different execution -providers such as CUDA, TensorRT, and CPU. It includes utility functions -for image preprocessing, postprocessing, and interfacing with the ONNXRuntime and TorchScript libraries. - -Functions: - det_postprocess: Postprocesses detection model outputs into sv.Detections. - semseg_postprocess: Postprocesses semantic segmentation model outputs into sv.Detections. - load_runtime: Returns an ONNXRuntime or TorchscriptRuntime instance configured for the given runtime type. - -Classes: - RuntimeTypes: Enum for the different runtime types. - ONNXRuntime: A class that interfaces with ONNX Runtime for model inference. - TorchscriptRuntime: A class that interfaces with TorchScript for model inference. -""" - -from abc import abstractmethod -from pathlib import Path -from time import perf_counter -from typing import Any - -import numpy as np - -try: - import torch - - TORCH_AVAILABLE = True -except ImportError: - TORCH_AVAILABLE = False - -try: - import onnxruntime as ort - - ORT_AVAILABLE = True -except ImportError: - ORT_AVAILABLE = False - - -# from supervision.detection.utils import mask_to_xyxy -from focoos.ports import ( - FocoosTask, - LatencyMetrics, - ModelMetadata, - OnnxRuntimeOpts, - RuntimeTypes, - TorchscriptRuntimeOpts, -) -from focoos.utils.logger import get_logger -from focoos.utils.system import get_cpu_name, get_gpu_info - -GPU_ID = 0 - -logger = get_logger() - - -class BaseRuntime: - """ - Abstract base class for runtime implementations. - - This class defines the interface that all runtime implementations must follow. - It provides methods for model initialization, inference, and performance benchmarking. - - Attributes: - model_path (str): Path to the model file. - opts (Any): Runtime-specific options. - model_metadata (ModelMetadata): Metadata about the model. - """ - - def __init__(self, model_path: str, opts: Any, model_metadata: ModelMetadata): - """ - Initialize the runtime with model path, options and metadata. - - Args: - model_path (str): Path to the model file. - opts (Any): Runtime-specific configuration options. - model_metadata (ModelMetadata): Metadata about the model. - """ - pass - - @abstractmethod - def __call__(self, im: np.ndarray) -> np.ndarray: - """ - Run inference on the input image. - - Args: - im (np.ndarray): Input image as a numpy array. - - Returns: - np.ndarray: Model output as a numpy array. - """ - pass - - @abstractmethod - def benchmark(self, iterations=20, size=640) -> LatencyMetrics: - """ - Benchmark the model performance. - - Args: - iterations (int, optional): Number of inference iterations to run. Defaults to 20. - size (int, optional): Input image size for benchmarking. Defaults to 640. - - Returns: - LatencyMetrics: Performance metrics including mean, median, and percentile latencies. - """ - pass - - -class ONNXRuntime(BaseRuntime): - """ - ONNX Runtime wrapper for model inference with different execution providers. - - This class implements the BaseRuntime interface for ONNX models, supporting - various execution providers like CUDA, TensorRT, OpenVINO, and CoreML. - It handles model initialization, provider configuration, warmup, inference, - and performance benchmarking. - - Attributes: - name (str): Name of the model derived from the model path. - opts (OnnxRuntimeOpts): Configuration options for the ONNX runtime. - model_metadata (ModelMetadata): Metadata about the model. - ort_sess (ort.InferenceSession): ONNX Runtime inference session. - active_providers (list): List of active execution providers. - dtype (np.dtype): Input data type for the model. - """ - - def __init__(self, model_path: str, opts: OnnxRuntimeOpts, model_metadata: ModelMetadata): - self.logger = get_logger() - - self.logger.debug(f"๐Ÿ”ง [onnxruntime device] {ort.get_device()}") - - self.name = Path(model_path).stem - self.opts = opts - self.model_metadata = model_metadata - - # Setup session options - options = ort.SessionOptions() - options.log_severity_level = 0 if opts.verbose else 2 - options.enable_profiling = opts.verbose - - # Setup providers - self.providers = self._setup_providers(model_dir=Path(model_path).parent) - self.active_provider = self.providers[0][0] - self.logger.info(f"[onnxruntime] using: {self.active_provider}") - # Create session - self.ort_sess = ort.InferenceSession(model_path, options, providers=self.providers) - - if self.opts.trt and self.providers[0][0] == "TensorrtExecutionProvider": - self.logger.info( - "๐ŸŸข [onnxruntime] TensorRT enabled. First execution may take longer as it builds the TRT engine." - ) - # Set input type - self.dtype = np.uint8 if self.ort_sess.get_inputs()[0].type == "tensor(uint8)" else np.float32 - - # Warmup - if self.opts.warmup_iter > 0: - self._warmup() - - def _setup_providers(self, model_dir: str): - providers = [] - available = ort.get_available_providers() - self.logger.info(f"[onnxruntime] available providers:{available}") - _dir = Path(model_dir) - models_root = _dir.parent - # Check and add providers in order of preference - provider_configs = [ - ( - "TensorrtExecutionProvider", - self.opts.trt, - { - "device_id": GPU_ID, - "trt_fp16_enable": self.opts.fp16, - "trt_force_sequential_engine_build": False, - "trt_engine_cache_enable": True, - "trt_engine_cache_path": str(_dir / ".trt_cache"), - "trt_ep_context_file_path": str(_dir), - "trt_timing_cache_enable": True, # Timing cache can be shared across multiple models if layers are the same - "trt_builder_optimization_level": 3, - "trt_timing_cache_path": str(models_root / ".trt_timing_cache"), - }, - ), - ( - "OpenVINOExecutionProvider", - self.opts.vino, - {"device_type": "MYRIAD_FP16", "enable_vpu_fast_compile": True, "num_of_threads": 1}, - ), - ( - "CUDAExecutionProvider", - self.opts.cuda, - { - "device_id": GPU_ID, - "arena_extend_strategy": "kSameAsRequested", - "gpu_mem_limit": 16 * 1024 * 1024 * 1024, - "cudnn_conv_algo_search": "EXHAUSTIVE", - "do_copy_in_default_stream": True, - }, - ), - ("CoreMLExecutionProvider", self.opts.coreml, {}), - ] - - for provider, enabled, config in provider_configs: - if enabled and provider in available: - providers.append((provider, config)) - elif enabled: - self.logger.warning(f"{provider} not found.") - - providers.append(("CPUExecutionProvider", {})) - return providers - - def _warmup(self): - self.logger.info("โฑ๏ธ [onnxruntime] Warming up model ..") - size = ( - self.model_metadata.im_size - if self.model_metadata.task == FocoosTask.DETECTION and self.model_metadata.im_size - else 640 - ) - np_image = np.random.rand(1, 3, size, size).astype(self.dtype) - input_name = self.ort_sess.get_inputs()[0].name - out_name = [output.name for output in self.ort_sess.get_outputs()] - - for _ in range(self.opts.warmup_iter): - self.ort_sess.run(out_name, {input_name: np_image}) - - self.logger.info("โฑ๏ธ [onnxruntime] Warmup done") - - def __call__(self, im: np.ndarray) -> list[np.ndarray]: - """ - Run inference on the input image. - - Args: - im (np.ndarray): Input image as a numpy array. - - Returns: - list[np.ndarray]: Model outputs as a list of numpy arrays. - """ - input_name = self.ort_sess.get_inputs()[0].name - out_name = [output.name for output in self.ort_sess.get_outputs()] - out = self.ort_sess.run(out_name, {input_name: im}) - return out - - def benchmark(self, iterations=20, size=640) -> LatencyMetrics: - """ - Benchmark the model performance. - - Runs multiple inference iterations and measures execution time to calculate - performance metrics like FPS, mean latency, and other statistics. - - Args: - iterations (int, optional): Number of inference iterations to run. Defaults to 20. - size (int or tuple, optional): Input image size for benchmarking. Defaults to 640. - - Returns: - LatencyMetrics: Performance metrics including FPS, mean, min, max, and std latencies. - """ - gpu_info = get_gpu_info() - device_name = "CPU" - if gpu_info.devices is not None and len(gpu_info.devices) > 0: - device_name = gpu_info.devices[0].gpu_name - else: - device_name = get_cpu_name() - self.logger.warning(f"No GPU found, using CPU {device_name}.") - - self.logger.info(f"โฑ๏ธ [onnxruntime] Benchmarking latency on {device_name}..") - size = size if isinstance(size, (tuple, list)) else (size, size) - - np_input = (255 * np.random.random((1, 3, size[0], size[1]))).astype(self.dtype) - input_name = self.ort_sess.get_inputs()[0].name - out_name = [output.name for output in self.ort_sess.get_outputs()] - - durations = [] - for step in range(iterations + 5): - start = perf_counter() - self.ort_sess.run(out_name, {input_name: np_input}) - end = perf_counter() - - if step >= 5: # Skip first 5 iterations - durations.append((end - start) * 1000) - - durations = np.array(durations) - - metrics = LatencyMetrics( - fps=int(1000 / durations.mean()), - engine=f"onnx.{self.active_provider}", - mean=round(durations.mean().astype(float), 3), - max=round(durations.max().astype(float), 3), - min=round(durations.min().astype(float), 3), - std=round(durations.std().astype(float), 3), - im_size=size[0], - device=str(device_name), - ) - self.logger.info(f"๐Ÿ”ฅ FPS: {metrics.fps} Mean latency: {metrics.mean} ms ") - return metrics - - -class TorchscriptRuntime(BaseRuntime): - """ - TorchScript Runtime wrapper for model inference. - - This class implements the BaseRuntime interface for TorchScript models, - supporting both CPU and CUDA devices. It handles model initialization, - device placement, warmup, inference, and performance benchmarking. - - Attributes: - device (torch.device): Device to run inference on (CPU or CUDA). - opts (TorchscriptRuntimeOpts): Configuration options for the TorchScript runtime. - model (torch.jit.ScriptModule): Loaded TorchScript model. - """ - - def __init__( - self, - model_path: str, - opts: TorchscriptRuntimeOpts, - model_metadata: ModelMetadata, - ): - self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu") - self.logger = get_logger(name="TorchscriptEngine") - self.logger.info(f"๐Ÿ”ง [torchscript] Device: {self.device}") - self.opts = opts - self.model_metadata = model_metadata - map_location = None if torch.cuda.is_available() else "cpu" - - self.model = torch.jit.load(model_path, map_location=map_location) - self.model = self.model.to(self.device) - - if self.opts.warmup_iter > 0: - self.logger.info("โฑ๏ธ [torchscript] Warming up model..") - with torch.no_grad(): - size = ( - self.model_metadata.im_size - if self.model_metadata.task == FocoosTask.DETECTION and self.model_metadata.im_size - else 640 - ) - np_image = torch.rand(1, 3, size, size, device=self.device) - for _ in range(self.opts.warmup_iter): - self.model(np_image) - self.logger.info("โฑ๏ธ [torchscript] WARMUP DONE") - - def __call__(self, im: np.ndarray) -> list[np.ndarray]: - """ - Run inference on the input image. - - Args: - im (np.ndarray): Input image as a numpy array. - - Returns: - list[np.ndarray]: Model outputs as a list of numpy arrays. - """ - with torch.no_grad(): - torch_image = torch.from_numpy(im).to(self.device, dtype=torch.float32) - res = self.model(torch_image) - return [r.cpu().numpy() for r in res] - - def benchmark(self, iterations=20, size=640) -> LatencyMetrics: - """ - Benchmark the model performance. - - Runs multiple inference iterations and measures execution time to calculate - performance metrics like FPS, mean latency, and other statistics. - - Args: - iterations (int, optional): Number of inference iterations to run. Defaults to 20. - size (int or tuple, optional): Input image size for benchmarking. Defaults to 640. - - Returns: - LatencyMetrics: Performance metrics including FPS, mean, min, max, and std latencies. - """ - gpu_info = get_gpu_info() - device_name = "CPU" - if gpu_info.devices is not None and len(gpu_info.devices) > 0: - device_name = gpu_info.devices[0].gpu_name - else: - device_name = get_cpu_name() - self.logger.warning(f"No GPU found, using CPU {device_name}.") - self.logger.info("โฑ๏ธ [torchscript] Benchmarking latency..") - size = size if isinstance(size, (tuple, list)) else (size, size) - - torch_input = torch.rand(1, 3, size[0], size[1], device=self.device) - durations = [] - - with torch.no_grad(): - for step in range(iterations + 5): - start = perf_counter() - self.model(torch_input) - end = perf_counter() - - if step >= 5: # Skip first 5 iterations - durations.append((end - start) * 1000) - - durations = np.array(durations) - - metrics = LatencyMetrics( - fps=int(1000 / durations.mean().astype(float)), - engine="torchscript", - mean=round(durations.mean().astype(float), 3), - max=round(durations.max().astype(float), 3), - min=round(durations.min().astype(float), 3), - std=round(durations.std().astype(float), 3), - im_size=size[0], - device=str(device_name), - ) - self.logger.info(f"๐Ÿ”ฅ FPS: {metrics.fps} Mean latency: {metrics.mean} ms ") - return metrics - - -def load_runtime( - runtime_type: RuntimeTypes, - model_path: str, - model_metadata: ModelMetadata, - warmup_iter: int = 0, -) -> BaseRuntime: - """ - Creates and returns a runtime instance based on the specified runtime type. - Supports both ONNX and TorchScript runtimes with various execution providers. - - Args: - runtime_type (RuntimeTypes): The type of runtime to use. Can be one of: - - ONNX_CUDA32: ONNX runtime with CUDA FP32 - - ONNX_TRT32: ONNX runtime with TensorRT FP32 - - ONNX_TRT16: ONNX runtime with TensorRT FP16 - - ONNX_CPU: ONNX runtime with CPU - - ONNX_COREML: ONNX runtime with CoreML - - TORCHSCRIPT_32: TorchScript runtime with FP32 - model_path (str): Path to the model file (.onnx or .pt) - model_metadata (ModelMetadata): Model metadata containing task type, classes etc. - warmup_iter (int, optional): Number of warmup iterations before inference. Defaults to 0. - - Returns: - BaseRuntime: A configured runtime instance (ONNXRuntime or TorchscriptRuntime) - - Raises: - ImportError: If required dependencies (torch/onnxruntime) are not installed - """ - if runtime_type == RuntimeTypes.TORCHSCRIPT_32: - if not TORCH_AVAILABLE: - logger.error( - "โš ๏ธ Pytorch not found =( please install focoos with ['torch'] extra. See https://focoosai.github.io/focoos/setup/ for more details" - ) - raise ImportError("Pytorch not found") - opts = TorchscriptRuntimeOpts(warmup_iter=warmup_iter) - return TorchscriptRuntime(model_path, opts, model_metadata) - else: - if not ORT_AVAILABLE: - logger.error( - "โš ๏ธ onnxruntime not found =( please install focoos with one of 'cpu', 'cuda', 'tensorrt' extra. See https://focoosai.github.io/focoos/setup/ for more details" - ) - raise ImportError("onnxruntime not found") - opts = OnnxRuntimeOpts( - cuda=runtime_type == RuntimeTypes.ONNX_CUDA32, - trt=runtime_type in [RuntimeTypes.ONNX_TRT32, RuntimeTypes.ONNX_TRT16], - fp16=runtime_type == RuntimeTypes.ONNX_TRT16, - warmup_iter=warmup_iter, - coreml=runtime_type == RuntimeTypes.ONNX_COREML, - verbose=False, - ) - return ONNXRuntime(model_path, opts, model_metadata) diff --git a/focoos/structures.py b/focoos/structures.py new file mode 100644 index 00000000..395e2217 --- /dev/null +++ b/focoos/structures.py @@ -0,0 +1,1056 @@ +# Copyright (c) Facebook, Inc. and its affiliates. + +import copy +import itertools +import math +from enum import IntEnum, unique +from typing import Any, Dict, List, Optional, Tuple, Union + +import numpy as np +import pycocotools.mask as mask_util +import torch +from torch import device +from torch.nn import functional as F + +_RawBoxType = Union[List[float], Tuple[float, ...], torch.Tensor, np.ndarray] + + +class Boxes: + """ + This structure stores a list of boxes as a Nx4 torch.Tensor. + It supports some common methods about boxes + (`area`, `clip`, `nonempty`, etc), + and also behaves like a Tensor + (support indexing, `to(device)`, `.device`, and iteration over all boxes) + + Attributes: + tensor (torch.Tensor): float matrix of Nx4. Each row is (x1, y1, x2, y2). + """ + + def __init__(self, tensor: torch.Tensor): + """ + Args: + tensor (Tensor[float]): a Nx4 matrix. Each row is (x1, y1, x2, y2). + """ + tensor = tensor.to(torch.float32) + if tensor.numel() == 0: + # Use reshape, so we don't end up creating a new tensor that does not depend on + # the inputs (and consequently confuses jit) + tensor = tensor.reshape((-1, 4)).to(dtype=torch.float32) + assert tensor.dim() == 2 and tensor.size(-1) == 4, tensor.size() + + self.tensor = tensor + + def clone(self) -> "Boxes": + """ + Clone the Boxes. + + Returns: + Boxes + """ + return Boxes(self.tensor.clone()) + + def to(self, device: torch.device): + # Boxes are assumed float32 and does not support to(dtype) + return Boxes(self.tensor.to(device=device)) + + def area(self) -> torch.Tensor: + """ + Computes the area of all the boxes. + + Returns: + torch.Tensor: a vector with areas of each box. + """ + box = self.tensor + area = (box[:, 2] - box[:, 0]) * (box[:, 3] - box[:, 1]) + return area + + def clip(self, box_size: Tuple[int, int]) -> None: + """ + Clip (in place) the boxes by limiting x coordinates to the range [0, width] + and y coordinates to the range [0, height]. + + Args: + box_size (height, width): The clipping box's size. + """ + assert torch.isfinite(self.tensor).all(), "Box tensor contains infinite or NaN!" + h, w = box_size + x1 = self.tensor[:, 0].clamp(min=0, max=w) + y1 = self.tensor[:, 1].clamp(min=0, max=h) + x2 = self.tensor[:, 2].clamp(min=0, max=w) + y2 = self.tensor[:, 3].clamp(min=0, max=h) + self.tensor = torch.stack((x1, y1, x2, y2), dim=-1) + + def nonempty(self, threshold: float = 0.0) -> torch.Tensor: + """ + Find boxes that are non-empty. + A box is considered empty, if either of its side is no larger than threshold. + + Returns: + Tensor: + a binary vector which represents whether each box is empty + (False) or non-empty (True). + """ + box = self.tensor + widths = box[:, 2] - box[:, 0] + heights = box[:, 3] - box[:, 1] + keep = (widths > threshold) & (heights > threshold) + return keep + + def __getitem__(self, item) -> "Boxes": + """ + Args: + item: int, slice, or a BoolTensor + + Returns: + Boxes: Create a new :class:`Boxes` by indexing. + + The following usage are allowed: + + 1. `new_boxes = boxes[3]`: return a `Boxes` which contains only one box. + 2. `new_boxes = boxes[2:10]`: return a slice of boxes. + 3. `new_boxes = boxes[vector]`, where vector is a torch.BoolTensor + with `length = len(boxes)`. Nonzero elements in the vector will be selected. + + Note that the returned Boxes might share storage with this Boxes, + subject to Pytorch's indexing semantics. + """ + if isinstance(item, int): + return Boxes(self.tensor[item].view(1, -1)) + b = self.tensor[item] + assert b.dim() == 2, "Indexing on Boxes with {} failed to return a matrix!".format(item) + return Boxes(b) + + def __len__(self) -> int: + return self.tensor.shape[0] + + def __repr__(self) -> str: + return "Boxes(" + str(self.tensor) + ")" + + def inside_box(self, box_size: Tuple[int, int], boundary_threshold: int = 0) -> torch.Tensor: + """ + Args: + box_size (height, width): Size of the reference box. + boundary_threshold (int): Boxes that extend beyond the reference box + boundary by more than boundary_threshold are considered "outside". + + Returns: + a binary vector, indicating whether each box is inside the reference box. + """ + height, width = box_size + inds_inside = ( + (self.tensor[..., 0] >= -boundary_threshold) + & (self.tensor[..., 1] >= -boundary_threshold) + & (self.tensor[..., 2] < width + boundary_threshold) + & (self.tensor[..., 3] < height + boundary_threshold) + ) + return inds_inside + + def get_centers(self) -> torch.Tensor: + """ + Returns: + The box centers in a Nx2 array of (x, y). + """ + return (self.tensor[:, :2] + self.tensor[:, 2:]) / 2 + + def scale(self, scale_x: float, scale_y: float) -> None: + """ + Scale the box with horizontal and vertical scaling factors + """ + self.tensor[:, 0::2] *= scale_x + self.tensor[:, 1::2] *= scale_y + + @classmethod + def cat(cls, boxes_list: List["Boxes"]) -> "Boxes": + """ + Concatenates a list of Boxes into a single Boxes + + Arguments: + boxes_list (list[Boxes]) + + Returns: + Boxes: the concatenated Boxes + """ + assert isinstance(boxes_list, (list, tuple)) + if len(boxes_list) == 0: + return cls(torch.empty(0)) + assert all([isinstance(box, Boxes) for box in boxes_list]) + + # use torch.cat (v.s. layers.cat) so the returned boxes never share storage with input + cat_boxes = cls(torch.cat([b.tensor for b in boxes_list], dim=0)) + return cat_boxes + + @property + def device(self) -> device: + return self.tensor.device + + # type "Iterator[torch.Tensor]", yield, and iter() not supported by torchscript + # https://github.com/pytorch/pytorch/issues/18627 + @torch.jit.unused + def __iter__(self): + """ + Yield a box as a Tensor of shape (4,) at a time. + """ + yield from self.tensor + + +def pairwise_masks_iou(masks1, masks2): + """ + Compute pairwise IoU between two sets of masks. + + Args: + masks1: torch.Tensor with type bool, shape (N, H, W) + masks2: torch.Tensor with type bool, shape (M, H, W) + + Returns: + ious: torch.Tensor with type float, shape (N, M) + """ + ious = torch.zeros((len(masks1), len(masks2))) + # Compute areas for each mask in masks1 and masks2 + areas1 = masks1.sum(dim=(1, 2)) # [N] + areas2 = masks2.sum(dim=(1, 2)) # [M] + + # Compute intersection between all pairs of masks + intersection = torch.logical_and(masks1.unsqueeze(1), masks2.unsqueeze(0)).sum(dim=(2, 3)) + union = areas1.unsqueeze(1) + areas2.unsqueeze(0) - intersection + # Handle empty masks (division by zero by adding epsilon) + ious = intersection.float() / (union.float() + 1e-6) + + return ious + + +def polygon_area(x, y): + # Using the shoelace formula + # https://stackoverflow.com/questions/24467972/calculate-area-of-polygon-given-x-y-coordinates + return 0.5 * np.abs(np.dot(x, np.roll(y, 1)) - np.dot(y, np.roll(x, 1))) + + +def polygons_to_bitmask(polygons: List[np.ndarray], height: int, width: int) -> np.ndarray: + """ + Args: + polygons (list[ndarray]): each array has shape (Nx2,) + height, width (int) + + Returns: + ndarray: a bool mask of shape (height, width) + """ + if len(polygons) == 0: + # COCOAPI does not support empty polygons + return np.zeros((height, width)).astype(bool) + rles = mask_util.frPyObjects(polygons, height, width) + rle = mask_util.merge(rles) + return mask_util.decode(rle).astype(bool) + + +def rasterize_polygons_within_box(polygons: List[np.ndarray], box: np.ndarray, mask_size: int) -> torch.Tensor: + """ + Rasterize the polygons into a mask image and + crop the mask content in the given box. + The cropped mask is resized to (mask_size, mask_size). + + This function is used when generating training targets for mask head in Mask R-CNN. + Given original ground-truth masks for an image, new ground-truth mask + training targets in the size of `mask_size x mask_size` + must be provided for each predicted box. This function will be called to + produce such targets. + + Args: + polygons (list[ndarray[float]]): a list of polygons, which represents an instance. + box: 4-element numpy array + mask_size (int): + + Returns: + Tensor: BoolTensor of shape (mask_size, mask_size) + """ + # 1. Shift the polygons w.r.t the boxes + w, h = box[2] - box[0], box[3] - box[1] + + polygons = copy.deepcopy(polygons) + for p in polygons: + p[0::2] = p[0::2] - box[0] + p[1::2] = p[1::2] - box[1] + + # 2. Rescale the polygons to the new box size + # max() to avoid division by small number + ratio_h = mask_size / max(h, 0.1) + ratio_w = mask_size / max(w, 0.1) + + if ratio_h == ratio_w: + for p in polygons: + p *= ratio_h + else: + for p in polygons: + p[0::2] *= ratio_w + p[1::2] *= ratio_h + + # 3. Rasterize the polygons with coco api + mask = polygons_to_bitmask(polygons, mask_size, mask_size) + mask = torch.from_numpy(mask) + return mask + + +class BitMasks: + """ + This class stores the segmentation masks for all objects in one image, in + the form of bitmaps. + + Attributes: + tensor: bool Tensor of N,H,W, representing N instances in the image. + """ + + def __init__(self, tensor: Union[torch.Tensor, np.ndarray]): + """ + Args: + tensor: bool Tensor of N,H,W, representing N instances in the image. + """ + if isinstance(tensor, torch.Tensor): + tensor = tensor.to(torch.bool) + else: + tensor = torch.as_tensor(tensor, dtype=torch.bool, device=torch.device("cpu")) + assert tensor.dim() == 3, tensor.size() + self.image_size = tensor.shape[1:] + self.tensor = tensor + + @torch.jit.unused + def to(self, *args: Any, **kwargs: Any) -> "BitMasks": + return BitMasks(self.tensor.to(*args, **kwargs)) + + @property + def device(self) -> torch.device: + return self.tensor.device + + @torch.jit.unused + def __getitem__(self, item: Union[int, slice, torch.BoolTensor]) -> "BitMasks": + """ + Returns: + BitMasks: Create a new :class:`BitMasks` by indexing. + + The following usage are allowed: + + 1. `new_masks = masks[3]`: return a `BitMasks` which contains only one mask. + 2. `new_masks = masks[2:10]`: return a slice of masks. + 3. `new_masks = masks[vector]`, where vector is a torch.BoolTensor + with `length = len(masks)`. Nonzero elements in the vector will be selected. + + Note that the returned object might share storage with this object, + subject to Pytorch's indexing semantics. + """ + if isinstance(item, int): + return BitMasks(self.tensor[item].unsqueeze(0)) + m = self.tensor[item] + assert m.dim() == 3, "Indexing on BitMasks with {} returns a tensor with shape {}!".format(item, m.shape) + return BitMasks(m) + + @torch.jit.unused + def __iter__(self): + yield from self.tensor + + @torch.jit.unused + def __repr__(self) -> str: + s = self.__class__.__name__ + "(" + s += "num_instances={})".format(len(self.tensor)) + return s + + def __len__(self) -> int: + return self.tensor.shape[0] + + def nonempty(self) -> torch.Tensor: + """ + Find masks that are non-empty. + + Returns: + Tensor: a BoolTensor which represents + whether each mask is empty (False) or non-empty (True). + """ + return self.tensor.flatten(1).any(dim=1) + + @staticmethod + def from_polygon_masks( + polygon_masks: List[List[np.ndarray]], + height: int, + width: int, + ) -> "BitMasks": + """ + Args: + polygon_masks (list[list[ndarray]]) + height, width (int) + """ + masks = [polygons_to_bitmask(p, height, width) for p in polygon_masks] + if len(masks): + return BitMasks(torch.stack([torch.from_numpy(x) for x in masks])) + else: + return BitMasks(torch.empty(0, height, width, dtype=torch.bool)) + + def get_bounding_boxes(self) -> Boxes: + """ + Returns: + Boxes: tight bounding boxes around bitmasks. + If a mask is empty, it's bounding box will be all zero. + """ + boxes = torch.zeros(self.tensor.shape[0], 4, dtype=torch.float32) + x_any = torch.any(self.tensor, dim=1) + y_any = torch.any(self.tensor, dim=2) + for idx in range(self.tensor.shape[0]): + x = torch.where(x_any[idx, :])[0] + y = torch.where(y_any[idx, :])[0] + if len(x) > 0 and len(y) > 0: + boxes[idx, :] = torch.as_tensor([x[0], y[0], x[-1] + 1, y[-1] + 1], dtype=torch.float32) + return Boxes(boxes) + + @staticmethod + def cat(bitmasks_list: List["BitMasks"]) -> "BitMasks": + """ + Concatenates a list of BitMasks into a single BitMasks + + Arguments: + bitmasks_list (list[BitMasks]) + + Returns: + BitMasks: the concatenated BitMasks + """ + assert isinstance(bitmasks_list, (list, tuple)) + assert len(bitmasks_list) > 0 + assert all(isinstance(bitmask, BitMasks) for bitmask in bitmasks_list) + + cat_bitmasks = type(bitmasks_list[0])(torch.cat([bm.tensor for bm in bitmasks_list], dim=0)) + return cat_bitmasks + + def area(self): + """ + Computes area of the mask. + """ + return self.tensor.sum(dim=(1, 2)) + + +@unique +class BoxMode(IntEnum): + """ + Enum of different ways to represent a box. + """ + + XYXY_ABS = 0 + """ + (x0, y0, x1, y1) in absolute floating points coordinates. + The coordinates in range [0, width or height]. + """ + XYWH_ABS = 1 + """ + (x0, y0, w, h) in absolute floating points coordinates. + """ + XYXY_REL = 2 + """ + Not yet supported! + (x0, y0, x1, y1) in range [0, 1]. They are relative to the size of the image. + """ + XYWH_REL = 3 + """ + Not yet supported! + (x0, y0, w, h) in range [0, 1]. They are relative to the size of the image. + """ + XYWHA_ABS = 4 + """ + (xc, yc, w, h, a) in absolute floating points coordinates. + (xc, yc) is the center of the rotated box, and the angle a is in degrees ccw. + """ + + @staticmethod + def convert(box: _RawBoxType, from_mode: "BoxMode", to_mode: "BoxMode") -> _RawBoxType: + """ + Args: + box: can be a k-tuple, k-list or an Nxk array/tensor, where k = 4 or 5 + from_mode, to_mode (BoxMode) + + Returns: + The converted box of the same type. + """ + if from_mode == to_mode: + return box + + original_type = type(box) + is_numpy = isinstance(box, np.ndarray) + single_box = isinstance(box, (list, tuple)) + if single_box: + assert len(box) == 4 or len(box) == 5, ( + "BoxMode.convert takes either a k-tuple/list or an Nxk array/tensor, where k == 4 or 5" + ) + arr = torch.tensor(box)[None, :] + else: + # avoid modifying the input box + if is_numpy: + arr = torch.from_numpy(np.asarray(box)).clone() + else: + arr = box.clone() + + assert to_mode not in [ + BoxMode.XYXY_REL, + BoxMode.XYWH_REL, + ] and from_mode not in [ + BoxMode.XYXY_REL, + BoxMode.XYWH_REL, + ], "Relative mode not yet supported!" + + if from_mode == BoxMode.XYWHA_ABS and to_mode == BoxMode.XYXY_ABS: + assert arr.shape[-1] == 5, "The last dimension of input shape must be 5 for XYWHA format" + original_dtype = arr.dtype + arr = arr.double() + + w = arr[:, 2] + h = arr[:, 3] + a = arr[:, 4] + c = torch.abs(torch.cos(a * math.pi / 180.0)) + s = torch.abs(torch.sin(a * math.pi / 180.0)) + # This basically computes the horizontal bounding rectangle of the rotated box + new_w = c * w + s * h + new_h = c * h + s * w + + # convert center to top-left corner + arr[:, 0] -= new_w / 2.0 + arr[:, 1] -= new_h / 2.0 + # bottom-right corner + arr[:, 2] = arr[:, 0] + new_w + arr[:, 3] = arr[:, 1] + new_h + + arr = arr[:, :4].to(dtype=original_dtype) + elif from_mode == BoxMode.XYWH_ABS and to_mode == BoxMode.XYWHA_ABS: + original_dtype = arr.dtype + arr = arr.double() + arr[:, 0] += arr[:, 2] / 2.0 + arr[:, 1] += arr[:, 3] / 2.0 + angles = torch.zeros((arr.shape[0], 1), dtype=arr.dtype) + arr = torch.cat((arr, angles), dim=1).to(dtype=original_dtype) + else: + if to_mode == BoxMode.XYXY_ABS and from_mode == BoxMode.XYWH_ABS: + arr[:, 2] += arr[:, 0] + arr[:, 3] += arr[:, 1] + elif from_mode == BoxMode.XYXY_ABS and to_mode == BoxMode.XYWH_ABS: + arr[:, 2] -= arr[:, 0] + arr[:, 3] -= arr[:, 1] + else: + raise NotImplementedError( + "Conversion from BoxMode {} to {} is not supported yet".format(from_mode, to_mode) + ) + + if single_box: + return original_type(arr.flatten().tolist()) + if is_numpy: + return arr.numpy() + else: + return arr + + +def pairwise_intersection(boxes1: Boxes, boxes2: Boxes) -> torch.Tensor: + """ + Given two lists of boxes of size N and M, + compute the intersection area between __all__ N x M pairs of boxes. + The box order must be (xmin, ymin, xmax, ymax) + + Args: + boxes1,boxes2 (Boxes): two `Boxes`. Contains N & M boxes, respectively. + + Returns: + Tensor: intersection, sized [N,M]. + """ + _boxes1, _boxes2 = boxes1.tensor, boxes2.tensor + width_height = torch.min(_boxes1[:, None, 2:], _boxes2[:, 2:]) - torch.max( + _boxes1[:, None, :2], _boxes2[:, :2] + ) # [N,M,2] + + width_height.clamp_(min=0) # [N,M,2] + intersection = width_height.prod(dim=2) # [N,M] + return intersection + + +# implementation from https://github.com/kuangliu/torchcv/blob/master/torchcv/utils/box.py +# with slight modifications +def pairwise_iou(boxes1: Boxes, boxes2: Boxes) -> torch.Tensor: + """ + Given two lists of boxes of size N and M, compute the IoU + (intersection over union) between **all** N x M pairs of boxes. + The box order must be (xmin, ymin, xmax, ymax). + + Args: + boxes1,boxes2 (Boxes): two `Boxes`. Contains N & M boxes, respectively. + + Returns: + Tensor: IoU, sized [N,M]. + """ + area1 = boxes1.area() # [N] + area2 = boxes2.area() # [M] + inter = pairwise_intersection(boxes1, boxes2) + + # handle empty boxes + iou = torch.where( + inter > 0, + inter / (area1[:, None] + area2 - inter), + torch.zeros(1, dtype=inter.dtype, device=inter.device), + ) + return iou + + +def pairwise_ioa(boxes1: Boxes, boxes2: Boxes) -> torch.Tensor: + """ + Similar to :func:`pariwise_iou` but compute the IoA (intersection over boxes2 area). + + Args: + boxes1,boxes2 (Boxes): two `Boxes`. Contains N & M boxes, respectively. + + Returns: + Tensor: IoA, sized [N,M]. + """ + area2 = boxes2.area() # [M] + inter = pairwise_intersection(boxes1, boxes2) + + # handle empty boxes + ioa = torch.where(inter > 0, inter / area2, torch.zeros(1, dtype=inter.dtype, device=inter.device)) + return ioa + + +def pairwise_point_box_distance(points: torch.Tensor, boxes: Boxes): + """ + Pairwise distance between N points and M boxes. The distance between a + point and a box is represented by the distance from the point to 4 edges + of the box. Distances are all positive when the point is inside the box. + + Args: + points: Nx2 coordinates. Each row is (x, y) + boxes: M boxes + + Returns: + Tensor: distances of size (N, M, 4). The 4 values are distances from + the point to the left, top, right, bottom of the box. + """ + x, y = points.unsqueeze(dim=2).unbind(dim=1) # (N, 1) + x0, y0, x1, y1 = boxes.tensor.unsqueeze(dim=0).unbind(dim=2) # (1, M) + return torch.stack([x - x0, y - y0, x1 - x, y1 - y], dim=2) + + +def matched_pairwise_iou(boxes1: Boxes, boxes2: Boxes) -> torch.Tensor: + """ + Compute pairwise intersection over union (IOU) of two sets of matched + boxes that have the same number of boxes. + Similar to :func:`pairwise_iou`, but computes only diagonal elements of the matrix. + + Args: + boxes1 (Boxes): bounding boxes, sized [N,4]. + boxes2 (Boxes): same length as boxes1 + Returns: + Tensor: iou, sized [N]. + """ + assert len(boxes1) == len(boxes2), "boxlists should have the samenumber of entries, got {}, {}".format( + len(boxes1), len(boxes2) + ) + area1 = boxes1.area() # [N] + area2 = boxes2.area() # [N] + box1, box2 = boxes1.tensor, boxes2.tensor + lt = torch.max(box1[:, :2], box2[:, :2]) # [N,2] + rb = torch.min(box1[:, 2:], box2[:, 2:]) # [N,2] + wh = (rb - lt).clamp(min=0) # [N,2] + inter = wh[:, 0] * wh[:, 1] # [N] + iou = inter / (area1 + area2 - inter) # [N] + return iou + + +def shapes_to_tensor(x: List[int], device: Optional[torch.device] = None) -> torch.Tensor: + """ + Turn a list of integer scalars or integer Tensor scalars into a vector, + in a way that's both traceable and scriptable. + + In tracing, `x` should be a list of scalar Tensor, so the output can trace to the inputs. + In scripting or eager, `x` should be a list of int. + """ + if torch.jit.is_scripting(): + return torch.as_tensor(x, device=device) + if torch.jit.is_tracing(): + assert all([isinstance(t, torch.Tensor) for t in x]), "Shape should be tensor during tracing!" + # as_tensor should not be used in tracing because it records a constant + ret = torch.stack(x) # type: ignore + if ret.device != device: # avoid recording a hard-coded device if not necessary + ret = ret.to(device=device) + return ret + return torch.as_tensor(x, device=device) + + +@torch.jit.script_if_tracing +def move_device_like(src: torch.Tensor, dst: torch.Tensor) -> torch.Tensor: + """ + Tracing friendly way to cast tensor to another tensor's device. Device will be treated + as constant during tracing, scripting the casting process as whole can workaround this issue. + """ + return src.to(dst.device) + + +class ImageList: + """ + Structure that holds a list of images (of possibly + varying sizes) as a single tensor. + This works by padding the images to the same size. + The original sizes of each image is stored in `image_sizes`. + + Attributes: + image_sizes (list[tuple[int, int]]): each tuple is (h, w). + During tracing, it becomes list[Tensor] instead. + """ + + def __init__(self, tensor: torch.Tensor, image_sizes: List[Tuple[int, int]]): + """ + Arguments: + tensor (Tensor): of shape (N, H, W) or (N, C_1, ..., C_K, H, W) where K >= 1 + image_sizes (list[tuple[int, int]]): Each tuple is (h, w). It can + be smaller than (H, W) due to padding. + """ + self.tensor = tensor + self.image_sizes = image_sizes + + def __len__(self) -> int: + return len(self.image_sizes) + + def __getitem__(self, idx) -> torch.Tensor: + """ + Access the individual image in its original size. + + Args: + idx: int or slice + + Returns: + Tensor: an image of shape (H, W) or (C_1, ..., C_K, H, W) where K >= 1 + """ + size = self.image_sizes[idx] + return self.tensor[idx, ..., : size[0], : size[1]] + + @torch.jit.unused + def to(self, *args: Any, **kwargs: Any) -> "ImageList": + cast_tensor = self.tensor.to(*args, **kwargs) + return ImageList(cast_tensor, self.image_sizes) + + @property + def device(self) -> device: + return self.tensor.device + + @staticmethod + def from_tensors( + tensors: List[torch.Tensor], + size_divisibility: int = 0, + pad_value: float = 0.0, + padding_constraints: Optional[Dict[str, int]] = None, + ) -> "ImageList": + """ + Args: + tensors: a tuple or list of `torch.Tensor`, each of shape (Hi, Wi) or + (C_1, ..., C_K, Hi, Wi) where K >= 1. The Tensors will be padded + to the same shape with `pad_value`. + size_divisibility (int): If `size_divisibility > 0`, add padding to ensure + the common height and width is divisible by `size_divisibility`. + This depends on the model and many models need a divisibility of 32. + pad_value (float): value to pad. + padding_constraints (optional[Dict]): If given, it would follow the format as + {"size_divisibility": int, "square_size": int}, where `size_divisibility` will + overwrite the above one if presented and `square_size` indicates the + square padding size if `square_size` > 0. + Returns: + an `ImageList`. + """ + assert len(tensors) > 0 + assert isinstance(tensors, (tuple, list)) + for t in tensors: + assert isinstance(t, torch.Tensor), type(t) + assert t.shape[:-2] == tensors[0].shape[:-2], t.shape + + image_sizes = [(im.shape[-2], im.shape[-1]) for im in tensors] + image_sizes_tensor = [shapes_to_tensor(x) for x in image_sizes] + max_size = torch.stack(image_sizes_tensor).max(0).values + + if padding_constraints is not None: + square_size = padding_constraints.get("square_size", 0) + if square_size > 0: + # pad to square. + max_size[0] = max_size[1] = square_size + if "size_divisibility" in padding_constraints: + size_divisibility = padding_constraints["size_divisibility"] + if size_divisibility > 1: + stride = size_divisibility + # the last two dims are H,W, both subject to divisibility requirement + max_size = (max_size + (stride - 1)).div(stride, rounding_mode="floor") * stride + + # handle weirdness of scripting and tracing ... + if torch.jit.is_scripting(): + max_size: List[int] = max_size.to(dtype=torch.long).tolist() + else: + if torch.jit.is_tracing(): + image_sizes = image_sizes_tensor + + if len(tensors) == 1: + # This seems slightly (2%) faster. + # TODO: check whether it's faster for multiple images as well + image_size = image_sizes[0] + padding_size = [ + 0, + max_size[-1] - image_size[1], + 0, + max_size[-2] - image_size[0], + ] + batched_imgs = F.pad(tensors[0], padding_size, value=pad_value).unsqueeze_(0) + else: + # max_size can be a tensor in tracing mode, therefore convert to list + batch_shape = [len(tensors)] + list(tensors[0].shape[:-2]) + list(max_size) + device = None if torch.jit.is_scripting() else ("cpu" if torch.jit.is_tracing() else None) + batched_imgs = tensors[0].new_full(batch_shape, pad_value, device=device) + batched_imgs = move_device_like(batched_imgs, tensors[0]) + for i, img in enumerate(tensors): + # Use `batched_imgs` directly instead of `img, pad_img = zip(tensors, batched_imgs)` + # Tracing mode cannot capture `copy_()` of temporary locals + batched_imgs[i, ..., : img.shape[-2], : img.shape[-1]].copy_(img) + + return ImageList(batched_imgs.contiguous(), image_sizes) + + +class Keypoints: + """ + Stores keypoint **annotation** data. GT Instances have a `gt_keypoints` property + containing the x,y location and visibility flag of each keypoint. This tensor has shape + (N, K, 3) where N is the number of instances and K is the number of keypoints per instance. + + The visibility flag follows the COCO format and must be one of three integers: + + * v=0: not labeled (in which case x=y=0) + * v=1: labeled but not visible + * v=2: labeled and visible + """ + + def __init__(self, keypoints: Union[torch.Tensor, np.ndarray, List[List[float]]]): + """ + Arguments: + keypoints: A Tensor, numpy array, or list of the x, y, and visibility of each keypoint. + The shape should be (N, K, 3) where N is the number of + instances, and K is the number of keypoints per instance. + """ + device = keypoints.device if isinstance(keypoints, torch.Tensor) else torch.device("cpu") + keypoints = torch.as_tensor(keypoints, dtype=torch.float32, device=device) + assert keypoints.dim() == 3 and keypoints.shape[2] == 3, keypoints.shape + self.tensor = keypoints + + def __len__(self) -> int: + return self.tensor.size(0) + + def to(self, *args: Any, **kwargs: Any) -> "Keypoints": + return type(self)(self.tensor.to(*args, **kwargs)) + + @property + def device(self) -> torch.device: + return self.tensor.device + + def __getitem__(self, item: Union[int, slice, torch.BoolTensor]) -> "Keypoints": + """ + Create a new `Keypoints` by indexing on this `Keypoints`. + + The following usage are allowed: + + 1. `new_kpts = kpts[3]`: return a `Keypoints` which contains only one instance. + 2. `new_kpts = kpts[2:10]`: return a slice of key points. + 3. `new_kpts = kpts[vector]`, where vector is a torch.ByteTensor + with `length = len(kpts)`. Nonzero elements in the vector will be selected. + + Note that the returned Keypoints might share storage with this Keypoints, + subject to Pytorch's indexing semantics. + """ + return Keypoints(self.tensor[item]) + + def __repr__(self) -> str: + s = self.__class__.__name__ + "(" + s += "num_instances={})".format(len(self.tensor)) + return s + + @staticmethod + def cat(keypoints_list: List["Keypoints"]) -> "Keypoints": + """ + Concatenates a list of Keypoints into a single Keypoints + + Arguments: + keypoints_list (list[Keypoints]) + + Returns: + Keypoints: the concatenated Keypoints + """ + assert isinstance(keypoints_list, (list, tuple)) + assert len(keypoints_list) > 0 + assert all(isinstance(keypoints, Keypoints) for keypoints in keypoints_list) + + cat_kpts = type(keypoints_list[0])(torch.cat([kpts.tensor for kpts in keypoints_list], dim=0)) + return cat_kpts + + +class Instances: + """ + This class represents a list of instances in an image. + It stores the attributes of instances (e.g., boxes, masks, labels, scores) as "fields". + All fields must have the same ``__len__`` which is the number of instances. + + Common fields include: + - boxes: Bounding boxes for each instance (Boxes object) + - masks: Instance segmentation masks (tensor of shape (N, H, W)) + - keypoints: Keypoint locations for each instance (tensor of shape (N, K, 3)) + - scores: Confidence scores for each instance (tensor of shape (N,)) + - classes: Class labels for each instance (tensor of shape (N,)) + + All other (non-field) attributes of this class are considered private: + they must start with '_' and are not modifiable by a user. + + Some basic usage: + + 1. Set/get/check a field: + + .. code-block:: python + + instances.gt_boxes = Boxes(...) + print(instances.pred_masks) # a tensor of shape (N, H, W) + print("gt_masks" in instances) + + 2. ``len(instances)`` returns the number of instances + 3. Indexing: ``instances[indices]`` will apply the indexing on all the fields + and returns a new :class:`Instances`. + Typically, ``indices`` is a integer vector of indices, + or a binary mask of length ``num_instances`` + + .. code-block:: python + + category_3_detections = instances[instances.pred_classes == 3] + confident_detections = instances[instances.scores > 0.9] + """ + + def __init__( + self, + image_size: Tuple[int, int], + boxes: Optional[Boxes] = None, + masks: Optional[BitMasks] = None, + keypoints: Optional[Keypoints] = None, + scores: Optional[torch.Tensor] = None, + classes: Optional[torch.Tensor] = None, + ): + """ + Args: + image_size (height, width): the spatial size of the image. + kwargs: fields to add to this `Instances`. Common fields include: + - boxes: Bounding boxes (Boxes object) + - masks: Instance segmentation masks (BitMasks object) + - keypoints: Keypoint locations (Keypoints object) + - scores: Confidence scores (tensor) + - classes: Class labels (tensor) + """ + self.image_size = image_size + self.boxes = boxes + self.masks = masks + self.keypoints = keypoints + self.scores = scores + self.classes = classes + self._fields = { + "boxes": self.boxes, + "masks": self.masks, + "keypoints": self.keypoints, + "scores": self.scores, + "classes": self.classes, + } + + # Tensor-like methods + def to(self, *args: Any, **kwargs: Any) -> "Instances": + """ + Returns: + Instances: all fields are called with a `to(device)`, if the field has this method. + """ + fields = {k: v.to(*args, **kwargs) if v is not None else None for k, v in self._fields.items()} + return Instances(self.image_size, **fields) + + def __getitem__(self, item: Union[int, slice, torch.BoolTensor]) -> "Instances": + """ + Args: + item: an index-like object and will be used to index all the fields. + + Returns: + If `item` is a string, return the data in the corresponding field. + Otherwise, returns an `Instances` where all fields are indexed by `item`. + """ + if isinstance(item, int): + if item >= len(self) or item < -len(self): + raise IndexError("Instances index out of range!") + else: + item = slice(item, None, len(self)) + + fields = {k: v[item] for k, v in self._fields.items() if v is not None} + return Instances(self.image_size, **fields) + + def __len__(self) -> int: + for v in self._fields.values(): + if v is not None: + # use __len__ because len() has to be int and is not friendly to tracing + return v.__len__() + raise NotImplementedError("Empty Instances does not support __len__!") + + def __iter__(self): + """ + Enables iteration over the instances, yielding each instance as a dictionary + of its fields. + + Returns: + Iterator: An iterator over the instances. + """ + self._iter_idx = 0 + return self + + def __next__(self): + """ + Returns the next instance when iterating. + + Returns: + dict: A dictionary containing the fields of the next instance. + + Raises: + StopIteration: When there are no more instances to iterate over. + """ + if self._iter_idx >= len(self): + raise StopIteration + + instance = self[self._iter_idx] + self._iter_idx += 1 + return instance + + @staticmethod + def cat(instance_lists: List["Instances"]) -> "Instances": + """ + Args: + instance_lists (list[Instances]) + + Returns: + Instances + """ + assert all(isinstance(i, Instances) for i in instance_lists) + assert len(instance_lists) > 0 + if len(instance_lists) == 1: + return instance_lists[0] + + image_size = instance_lists[0].image_size + if not isinstance(image_size, torch.Tensor): # could be a tensor in tracing + for i in instance_lists[1:]: + assert i.image_size == image_size + + new_fields = {} + for k in instance_lists[0]._fields.keys(): + values = [i._fields[k] for i in instance_lists] + v0 = values[0] + if isinstance(v0, torch.Tensor): + values = torch.cat(values, dim=0) + elif isinstance(v0, list): + values = list(itertools.chain(*values)) + elif hasattr(type(v0), "cat"): + values = type(v0).cat(values) + else: + raise ValueError("Unsupported type {} for concatenation".format(type(v0))) + new_fields[k] = values + return Instances(image_size, **new_fields) + + def __str__(self) -> str: + s = self.__class__.__name__ + "(" + s += "num_instances={}, ".format(len(self)) + s += "image_height={}, ".format(self.image_size[0]) + s += "image_width={}, ".format(self.image_size[1]) + s += "fields=[{}])".format(", ".join((f"{k}: {v}" for k, v in self._fields.items() if v is not None))) + return s + + __repr__ = __str__ diff --git a/focoos/trainer/__init__.py b/focoos/trainer/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/focoos/trainer/checkpointer.py b/focoos/trainer/checkpointer.py new file mode 100644 index 00000000..c242b445 --- /dev/null +++ b/focoos/trainer/checkpointer.py @@ -0,0 +1,374 @@ +# Copyright (c) FocoosAI +# Part of the code has been copied and adapted from Detectron2 (c) Facebook, Inc. and its affiliates. +import logging +import os +from typing import IO, Any, Dict, Iterable, List, Optional, Tuple, cast +from urllib.parse import parse_qs, urlparse + +import torch +import torch.nn as nn +from iopath.common.file_io import HTTPURLHandler, PathManager +from torch.nn.parallel import DataParallel, DistributedDataParallel + +from focoos.models.focoos_model import BaseModelNN +from focoos.utils.checkpoint import IncompatibleKeys +from focoos.utils.distributed import comm +from focoos.utils.logger import get_logger + +logger = get_logger(__name__) + +__all__ = ["Checkpointer", "PeriodicCheckpointer"] + + +class Checkpointer: + """ + A checkpointer that can save/load model as well as extra checkpointable + objects. + """ + + def __init__( + self, + model: BaseModelNN, + save_dir: str = "", + *, + save_to_disk: bool = True, + **checkpointables: Any, + ) -> None: + """ + Args: + model (nn.Module): model. + save_dir (str): a directory to save and find checkpoints. + save_to_disk (bool): if True, save checkpoint to disk, otherwise + disable saving for this checkpointer. + checkpointables (object): any checkpointable objects, i.e., objects + that have the ``state_dict()`` and ``load_state_dict()`` method. For + example, it can be used like + `Checkpointer(model, "dir", optimizer=optimizer)`. + """ + save_to_disk = comm.is_main_process() and save_to_disk + if isinstance(model, (DistributedDataParallel, DataParallel)): + model = model.module + self.model = model + self.checkpointables: Dict[str, Any] = {} + for k, v in checkpointables.items(): + self.add_checkpointable(k, v) + self.logger: logging.Logger = logging.getLogger(__name__) + self.save_dir = save_dir + self.save_to_disk = save_to_disk + # Default PathManager, support HTTP URLs (for backward compatibility in open source). + # A user may want to use a different project-specific PathManager + self.path_manager: PathManager = PathManager() + self.path_manager.register_handler(HTTPURLHandler()) + self._parsed_url_during_load = None + + def add_checkpointable(self, key: str, checkpointable: Any) -> None: + """ + Add checkpointable object for this checkpointer to track. + + Args: + key (str): the key used to save the object + checkpointable: any object with ``state_dict()`` and + ``load_state_dict()`` method + """ + if key in self.checkpointables: + raise KeyError(f"Key {key} already used in the Checkpointer") + if not hasattr(checkpointable, "state_dict"): + raise TypeError("add_checkpointable needs an object with 'state_dict()' method.") + self.checkpointables[key] = checkpointable + + def save(self, name: str, **kwargs: Any) -> None: + """ + Dump model and checkpointables to a file. + + Args: + name (str): name of the file. + kwargs (dict): extra arbitrary data to save. + """ + if not self.save_dir or not self.save_to_disk: + return + + data = {} + data["model"] = self.model.state_dict() + for key, obj in self.checkpointables.items(): + data[key] = obj.state_dict() + data.update(kwargs) + + basename = "{}.pth".format(name) + save_file = os.path.join(self.save_dir, basename) + assert os.path.basename(save_file) == basename, basename + self.logger.info("Saving checkpoint to {}".format(save_file)) + with self.path_manager.open(save_file, "wb") as f: + torch.save(data, cast(IO[bytes], f)) + self.tag_last_checkpoint(basename) + + def load(self, path: str, checkpointables: Optional[List[str]] = None) -> Dict[str, Any]: + """ + Load from the given checkpoint. + + Args: + path (str): path or url to the checkpoint. If empty, will not load + anything. + checkpointables (list): List of checkpointable names to load. If not + specified (None), will load all the possible checkpointables. + Returns: + dict: + extra data loaded from the checkpoint that has not been + processed. For example, those saved with + :meth:`.save(**extra_data)`. + """ + assert self._parsed_url_during_load is None + need_sync = False + logger.info("Loading from {} ...".format(path)) + + if path and isinstance(self.model, DistributedDataParallel): + has_file = os.path.isfile(path) + all_has_file = comm.all_gather(has_file) + if not all_has_file[0]: + raise OSError(f"File {path} not found on main worker.") + if not all(all_has_file): + logger.warning(f"Not all workers can read checkpoint {path}. Training may fail to fully resume.") + need_sync = True + if not has_file: + path = None # type: ignore # don't load if not readable + + if not path: + # no checkpoint provided + self.logger.info("No checkpoint found. Initializing model from scratch") + return {} + else: + parsed_url = urlparse(path) + self._parsed_url_during_load = parsed_url + path = parsed_url._replace(query="").geturl() # remove query from filename + + self.logger.info("[Checkpointer] Loading from {} ...".format(path)) + if not os.path.isfile(path): + path = self.path_manager.get_local_path(path) + assert os.path.isfile(path), "Checkpoint {} not found!".format(path) + + # Load and preprocess checkpoint file + checkpoint = self._load_file(path) + # Load the checkpoint into the model + _ = self._load_model(checkpoint) + + for key in self.checkpointables if checkpointables is None else checkpointables: + if key in checkpoint: + self.logger.info("Loading {} from {} ...".format(key, path)) + obj = self.checkpointables[key] + obj.load_state_dict(checkpoint.pop(key)) + + # return any further checkpoint data + ret = checkpoint + if need_sync: + logger.info("Broadcasting model states from main worker ...") + self.model._sync_params_and_buffers() + self._parsed_url_during_load = None # reset to None + return ret + + def has_checkpoint(self) -> bool: + """ + Returns: + bool: whether a checkpoint exists in the target directory. + """ + save_file = os.path.join(self.save_dir, "last_checkpoint") + return self.path_manager.exists(save_file) + + def get_checkpoint_file(self) -> str: + """ + Returns: + str: The latest checkpoint file in target directory. + """ + save_file = os.path.join(self.save_dir, "last_checkpoint") + try: + with self.path_manager.open(save_file, "r") as f: + last_saved = f.read().strip() + except IOError: + # if file doesn't exist, maybe because it has just been + # deleted by a separate process + return "" + return os.path.join(self.save_dir, last_saved) # type: ignore + + def get_all_checkpoint_files(self) -> List[str]: + """ + Returns: + list: All available checkpoint files (.pth files) in target + directory. + """ + all_model_checkpoints = [ + os.path.join(self.save_dir, file) + for file in self.path_manager.ls(self.save_dir) + if self.path_manager.isfile(os.path.join(self.save_dir, file)) and file.endswith(".pth") + ] + return all_model_checkpoints + + def resume_or_load(self, path: str, *, resume: bool = True) -> Dict[str, Any]: + """ + If `resume` is True, this method attempts to resume from the last + checkpoint, if exists. Otherwise, load checkpoint from the given path. + This is useful when restarting an interrupted training job. + + Args: + path (str): path to the checkpoint. + resume (bool): if True, resume from the last checkpoint if it exists + and load the model together with all the checkpointables. Otherwise + only load the model without loading any checkpointables. + + Returns: + same as :meth:`load`. + """ + if resume and self.has_checkpoint(): + path = self.get_checkpoint_file() + return self.load(path) + else: + return self.load(path, checkpointables=[]) + + def tag_last_checkpoint(self, last_filename_basename: str) -> None: + """ + Tag the last checkpoint. + + Args: + last_filename_basename (str): the basename of the last filename. + """ + save_file = os.path.join(self.save_dir, "last_checkpoint") + with self.path_manager.open(save_file, "w") as f: + f.write(last_filename_basename) # type: ignore + + def _load_file(self, filename: str) -> Dict[str, Any]: + """ + Load a checkpoint file. Can be overwritten by subclasses to support + different formats. + + Args: + filename (str): a locally mounted file path. + Returns: + dict: with keys "model" and optionally others that are saved by + the checkpointer dict["model"] must be a dict which maps strings + to torch.Tensor or numpy arrays. + """ + loaded = torch.load(filename, map_location=torch.device("cpu"), weights_only=True) + if "model" not in loaded: + loaded = {"model": loaded} + assert self._parsed_url_during_load is not None, "`_load_file` must be called inside `load`" + parsed_url = self._parsed_url_during_load + queries = parse_qs(parsed_url.query) + + if len(queries) > 0: # probably can be removed, from detectron2 + logger.error(f"Unsupported query remaining: f{queries}, orginal filename: {parsed_url.geturl()}") + raise ValueError(f"Unsupported query remaining: f{queries}, orginal filename: {parsed_url.geturl()}") + return loaded + + def _load_model(self, checkpoint: Any) -> IncompatibleKeys: + """ + Load weights from a checkpoint. + + Args: + checkpoint (Any): checkpoint contains the weights. + + Returns: + ``NamedTuple`` with ``missing_keys``, ``unexpected_keys``, + and ``incorrect_shapes`` fields: + * **missing_keys** is a list of str containing the missing keys + * **unexpected_keys** is a list of str containing the unexpected keys + * **incorrect_shapes** is a list of (key, shape in checkpoint, shape in model) + + This is just like the return value of + :func:`torch.nn.Module.load_state_dict`, but with extra support + for ``incorrect_shapes``. + """ + checkpoint_state_dict = checkpoint.pop("model") + + incompatible = self.model.load_state_dict(checkpoint_state_dict, strict=False) + + return incompatible + + +class PeriodicCheckpointer: + """ + Save checkpoints periodically. When `.step(iteration)` is called, it will + execute `checkpointer.save` on the given checkpointer, if iteration is a + multiple of period or if `max_iter` is reached. + + Attributes: + checkpointer (Checkpointer): the underlying checkpointer object + """ + + def __init__( + self, + checkpointer: Checkpointer, + period: int, + max_iter: Optional[int] = None, + max_to_keep: Optional[int] = None, + file_prefix: str = "model", + ) -> None: + """ + Args: + checkpointer: the checkpointer object used to save checkpoints. + period (int): the period to save checkpoint. + max_iter (int): maximum number of iterations. When it is reached, + a checkpoint named "{file_prefix}_final" will be saved. + max_to_keep (int): maximum number of most current checkpoints to keep, + previous checkpoints will be deleted + file_prefix (str): the prefix of checkpoint's filename + """ + self.checkpointer = checkpointer + self.period = int(period) + self.max_iter = max_iter + if max_to_keep is not None: + assert max_to_keep > 0 + self.max_to_keep = max_to_keep + self.recent_checkpoints: List[str] = [] + self.path_manager: PathManager = checkpointer.path_manager + self.file_prefix = file_prefix + + def step(self, iteration: int, **kwargs: Any) -> None: + """ + Perform the appropriate action at the given iteration. + + Args: + iteration (int): the current iteration, ranged in [0, max_iter-1]. + kwargs (Any): extra data to save, same as in + :meth:`Checkpointer.save`. + """ + iteration = int(iteration) + additional_state = {"iteration": iteration} + additional_state.update(kwargs) + + if (iteration + 1) % self.period == 0: + self.checkpointer.save("{}_{:07d}".format(self.file_prefix, iteration), **additional_state) + + if self.max_to_keep is not None: + self.recent_checkpoints.append(self.checkpointer.get_checkpoint_file()) + if len(self.recent_checkpoints) > self.max_to_keep: + file_to_delete = self.recent_checkpoints.pop(0) + if self.path_manager.exists(file_to_delete) and not file_to_delete.endswith( + f"{self.file_prefix}_final.pth" + ): + self.path_manager.rm(file_to_delete) + + if self.max_iter is not None: + if iteration >= self.max_iter - 1: + self.checkpointer.save(f"{self.file_prefix}_final", **additional_state) + + def save(self, name: str, **kwargs: Any) -> None: + """ + Same argument as :meth:`Checkpointer.save`. + Use this method to manually save checkpoints outside the schedule. + + Args: + name (str): file name. + kwargs (Any): extra data to save, same as in + :meth:`Checkpointer.save`. + """ + self.checkpointer.save(name, **kwargs) + + +def _named_modules_with_dup(model: nn.Module, prefix: str = "") -> Iterable[Tuple[str, nn.Module]]: + """ + The same as `model.named_modules()`, except that it includes + duplicated modules that have more than one name. + """ + yield prefix, model + for name, module in model._modules.items(): + if module is None: + continue + submodule_prefix = prefix + ("." if prefix else "") + name + yield from _named_modules_with_dup(module, submodule_prefix) diff --git a/focoos/trainer/evaluation/__init__.py b/focoos/trainer/evaluation/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/focoos/trainer/evaluation/classification_evaluation.py b/focoos/trainer/evaluation/classification_evaluation.py new file mode 100644 index 00000000..992d6400 --- /dev/null +++ b/focoos/trainer/evaluation/classification_evaluation.py @@ -0,0 +1,286 @@ +from collections import OrderedDict +from typing import List + +import torch + +from focoos.data.datasets.dict_dataset import DictDataset +from focoos.data.mappers.classification_dataset_mapper import ClassificationDatasetDict +from focoos.models.fai_cls.ports import ClassificationModelOutput +from focoos.trainer.evaluation.evaluator import DatasetEvaluator +from focoos.utils.distributed.comm import all_gather, is_main_process, synchronize +from focoos.utils.logger import get_logger + +logger = get_logger(__name__) + + +class ClassificationEvaluator(DatasetEvaluator): + """Evaluator for classification tasks with comprehensive metrics computation. + + This evaluator computes various classification metrics including accuracy, precision, + recall, and F1 score both per-class and as macro/weighted averages. It supports + distributed evaluation across multiple processes. + + Attributes: + dataset_dict (DictDataset): Dataset containing ground truth annotations. + metadata: Metadata from the dataset containing class information. + num_classes (int): Number of classes in the classification task. + class_names (List[str]): Names of the classes. + """ + + def __init__( + self, + dataset_dict: DictDataset, + distributed=True, + ): + """Initialize the ClassificationEvaluator. + + Args: + dataset_dict (DictDataset): Dataset in DictDataset format containing + the ground truth annotations. + distributed (bool, optional): If True, evaluation will be distributed + across multiple processes. Defaults to True. + """ + self.dataset_dict = dataset_dict + self.metadata = self.dataset_dict.metadata + self._distributed = distributed + self._cpu_device = torch.device("cpu") + self.num_classes = self.metadata.num_classes + self.class_names = self.metadata.thing_classes + + self._predictions = [] + self._targets = [] + + @classmethod + def from_datasetdict(cls, dataset_dict, **kwargs): + """Create ClassificationEvaluator instance from a dataset dictionary. + + Args: + dataset_dict: Dataset dictionary containing the data and metadata. + **kwargs: Additional keyword arguments passed to the constructor. + + Returns: + ClassificationEvaluator: New instance of the evaluator. + """ + return cls(dataset_dict=dataset_dict, **kwargs) + + def reset(self): + """Clear stored predictions and targets. + + This method resets the internal state of the evaluator by clearing + all accumulated predictions and ground truth targets. + """ + self._predictions = [] + self._targets = [] + + def process(self, inputs: List[ClassificationDatasetDict], outputs: List[ClassificationModelOutput]): + """Process a batch of inputs and outputs for evaluation. + + This method extracts predictions and ground truth labels from the provided + inputs and outputs, then stores them for later evaluation. + + Args: + inputs (List[ClassificationDatasetDict]): List of input dictionaries, + each containing ground truth information. Expected to have 'label' + field or 'annotations' with category_id. + outputs (List[ClassificationModelOutput]): List of model outputs, + each containing 'logits' field with predicted class logits or + ClassificationModelOutput instances. + + Note: + - Ground truth labels are extracted from input['label'] or + input['annotations'][0]['category_id'] + - Predictions are extracted from output['logits'] or output.logits + - Items with missing labels or logits are skipped with warnings + """ + for input_item, output_item in zip(inputs, outputs): + # Get ground truth label from input + label = None + if "label" in input_item: + label = input_item["label"] + elif hasattr(input_item, "label"): + label = input_item.label + elif "annotations" in input_item and len(input_item["annotations"]) > 0: + # Handle label from annotations format + label = input_item["annotations"][0].get("category_id", None) + + if label is None: + logger.warning(f"Could not find label in input item: {input_item}") + continue + + # Get model predictions from output + logits = None + if isinstance(output_item, dict) and "logits" in output_item: + logits = output_item["logits"] + elif hasattr(output_item, "logits"): + # Handle ClassificationModelOutput objects + logits = output_item.logits + + if logits is None: + logger.warning(f"Could not find logits in output item: {output_item}") + continue + + # Move tensors to CPU for evaluation + logits = logits.to(self._cpu_device) + + # For image classification, logits will be [num_classes] + # For batch processing, it could be [batch_size, num_classes] + if logits.dim() > 1: + # Assume first dimension is batch size + predicted_class = torch.argmax(logits, dim=1).item() + else: + predicted_class = torch.argmax(logits, dim=0).item() + + # Store prediction and ground truth + self._predictions.append(predicted_class) + self._targets.append(label) + + def _compute_confusion_matrix(self, y_true, y_pred, num_classes): + """Compute confusion matrix for classification evaluation. + + Args: + y_true (torch.Tensor): Ground truth labels tensor. + y_pred (torch.Tensor): Predicted labels tensor. + num_classes (int): Number of classes in the classification task. + + Returns: + torch.Tensor: Confusion matrix of shape (num_classes, num_classes). + Element [i,j] represents the number of samples with true label i + that were predicted as label j. + """ + confusion_matrix = torch.zeros(num_classes, num_classes, dtype=torch.long) + for t, p in zip(y_true, y_pred): + confusion_matrix[t, p] += 1 + return confusion_matrix + + def evaluate(self): + """Evaluate classification metrics on accumulated predictions. + + Computes comprehensive classification metrics including accuracy, + per-class precision/recall/F1, and macro/weighted averages. + + Returns: + OrderedDict: Dictionary containing evaluation metrics with the following keys: + - 'Accuracy': Overall classification accuracy (%) + - 'Macro-Precision': Macro-averaged precision (%) + - 'Macro-Recall': Macro-averaged recall (%) + - 'Macro-F1': Macro-averaged F1 score (%) + - 'Weighted-Precision': Weighted precision (%) + - 'Weighted-Recall': Weighted recall (%) + - 'Weighted-F1': Weighted F1 score (%) + - 'Precision-{class_name}': Per-class precision (%) + - 'Recall-{class_name}': Per-class recall (%) + - 'F1-{class_name}': Per-class F1 score (%) + + Note: + - In distributed mode, only the main process returns results + - All metrics are expressed as percentages + - Returns empty dict if no predictions are available + - Results are wrapped in 'classification' key for trainer compatibility + """ + if self._distributed: + synchronize() + predictions_list = all_gather(self._predictions) + targets_list = all_gather(self._targets) + + # Flatten gathered lists + predictions = [] + targets = [] + for p_list, t_list in zip(predictions_list, targets_list): + if p_list is not None: + predictions.extend(p_list) + if t_list is not None: + targets.extend(t_list) + + if not is_main_process(): + return + else: + predictions = self._predictions + targets = self._targets + + # Check if we have predictions to evaluate + if len(predictions) == 0: + logger.warning("No predictions to evaluate") + return OrderedDict({"classification": {}}) + + # Convert lists to tensors + y_true = torch.tensor(targets, dtype=torch.long) + y_pred = torch.tensor(predictions, dtype=torch.long) + + # Compute confusion matrix + cm = self._compute_confusion_matrix(y_true, y_pred, self.num_classes) + + # Calculate accuracy + accuracy = 100.0 * cm.diag().sum().float() / cm.sum().float() + + # Calculate per-class metrics + tp = cm.diag() # True positives for each class + pred_sum = cm.sum(dim=0) # Sum over actual classes (columns) + target_sum = cm.sum(dim=1) # Sum over predicted classes (rows) + + # Precision for each class + precision_per_class = torch.zeros(self.num_classes, dtype=torch.float) + for i in range(self.num_classes): + if pred_sum[i] > 0: + precision_per_class[i] = 100.0 * tp[i].float() / pred_sum[i].float() + + # Recall for each class + recall_per_class = torch.zeros(self.num_classes, dtype=torch.float) + for i in range(self.num_classes): + if target_sum[i] > 0: + recall_per_class[i] = 100.0 * tp[i].float() / target_sum[i].float() + + # F1 score for each class + f1_per_class = torch.zeros(self.num_classes, dtype=torch.float) + for i in range(self.num_classes): + if precision_per_class[i] + recall_per_class[i] > 0: + f1_per_class[i] = ( + 2 * (precision_per_class[i] * recall_per_class[i]) / (precision_per_class[i] + recall_per_class[i]) + ) + + # Calculate macro averages + valid_precision_classes = (pred_sum > 0).sum().item() + macro_precision = precision_per_class.sum() / max(valid_precision_classes, 1) + + valid_recall_classes = (target_sum > 0).sum().item() + macro_recall = recall_per_class.sum() / max(valid_recall_classes, 1) + + if macro_precision + macro_recall > 0: + macro_f1 = 2 * (macro_precision * macro_recall) / (macro_precision + macro_recall) + else: + macro_f1 = torch.tensor(0.0) + + # Calculate weighted averages + weights = target_sum.float() / target_sum.sum().float() + weighted_precision = (precision_per_class * weights).sum() + weighted_recall = (recall_per_class * weights).sum() + + if weighted_precision + weighted_recall > 0: + weighted_f1 = 2 * (weighted_precision * weighted_recall) / (weighted_precision + weighted_recall) + else: + weighted_f1 = torch.tensor(0.0) + + # Create results dictionary + results = OrderedDict() + results["Accuracy"] = accuracy.item() + results["Macro-Precision"] = macro_precision.item() + results["Macro-Recall"] = macro_recall.item() + results["Macro-F1"] = macro_f1.item() + results["Weighted-Precision"] = weighted_precision.item() + results["Weighted-Recall"] = weighted_recall.item() + results["Weighted-F1"] = weighted_f1.item() + + # Add per-class metrics + if self.class_names is not None: + for i, class_name in enumerate(self.class_names): + if i < self.num_classes: + results[f"Precision-{class_name}"] = precision_per_class[i].item() + results[f"Recall-{class_name}"] = recall_per_class[i].item() + results[f"F1-{class_name}"] = f1_per_class[i].item() + + # Log results + logger.info("Classification Evaluation Results:") + for k, v in results.items(): + logger.info(f" {k}: {v:.2f}") + + # Return results in the expected format for trainer + return OrderedDict({"classification": results}) diff --git a/focoos/trainer/evaluation/detection_evaluation.py b/focoos/trainer/evaluation/detection_evaluation.py new file mode 100644 index 00000000..d9ac5d90 --- /dev/null +++ b/focoos/trainer/evaluation/detection_evaluation.py @@ -0,0 +1,420 @@ +# Copyright (c) FocoosAI +import copy +import itertools +from collections import OrderedDict +from typing import List + +import numpy as np +import pycocotools.mask as mask_util +import torch + +from focoos.ports import DatasetEntry + +try: + import faster_coco_eval + + # Replace pycocotools with faster_coco_eval + faster_coco_eval.init_as_pycocotools() +except ImportError: + pass + +from pycocotools.coco import COCO +from pycocotools.cocoeval import COCOeval +from tabulate import tabulate + +import focoos.utils.distributed.comm as comm +from focoos.data.datasets.dict_dataset import DictDataset +from focoos.structures import BoxMode, Instances +from focoos.utils.logger import create_small_table, get_logger + +from .evaluator import DatasetEvaluator + +logger = get_logger(__name__) + + +class DetectionEvaluator(DatasetEvaluator): + """ + Evaluate object detection and instance segmentation predictions using COCO-style metrics. + + This evaluator supports evaluating: + - Bounding box detection (task="bbox") + - Instance segmentation (task="segm") + + The metrics include: + - Average Precision (AP) at different IoU thresholds + - AP for different object scales (small, medium, large) + - Per-category AP + + The metrics range from 0 to 100 (instead of 0 to 1), where a -1 or NaN means + the metric cannot be computed (e.g. due to no predictions made). + + This evaluator can be used with any dataset that follows the COCO data format. + """ + + def __init__( + self, + dataset_dict: DictDataset, + task="bbox", + distributed=True, + ): + """ + Args: + dataset_dict: Dataset in DictDataset format containing the ground truth annotations + task: Evaluation task, one of "bbox", "segm", or "keypoints" + distributed: If True, evaluation will be distributed across multiple processes. + If False, evaluation runs only in the current process. + """ + self._distributed = distributed + self.dataset_dict = dataset_dict + self.metadata = self.dataset_dict.metadata + + assert task in {"bbox", "segm"}, f"Got unknown task: {task}!" + self.iou_type = task + + self.num_classes = self.metadata.num_classes + self.class_names = self.metadata.thing_classes + + self.cpu_device = torch.device("cpu") + + self._predictions = [] + # self._inputs = [] + + def reset(self): + """Clear stored predictions and inputs.""" + self._predictions = [] + # self._inputs = [] + + def process(self, inputs: List[DatasetEntry], outputs): + """ + Process one batch of model inputs and outputs. + + Args: + inputs: List of dicts containing input image metadata like "image_id", "height", "width" + outputs: List of dicts containing model predictions with key "instances" containing + detection/segmentation results as Instances objects + """ + for input, output in zip(inputs, outputs): + assert isinstance(input, DatasetEntry), "Input must be a DatasetEntry!" + assert input.image_id is not None, "Image ID must be present in the input!" + + if "instances" in output: + prediction = { + "image_id": input.image_id, + "instances": self.instances_to_coco_json(output["instances"].to(self.cpu_device), input.image_id), + } + self._predictions.append(prediction) + else: + raise Exception("No instances in output?!") + + def evaluate(self): + """ + Evaluate all stored predictions against ground truth. + + For distributed training, aggregates predictions from all workers on rank 0. + + Returns: + dict: Evaluation results with metrics like AP, AP50, AP75 etc. + """ + if self._distributed: + comm.synchronize() + predictions = comm.gather(self._predictions, dst=0) + predictions = list(itertools.chain(*predictions)) # type: ignore + + if not comm.is_main_process(): + return {} + else: + predictions = self._predictions + + if len(predictions) == 0: + logger.error("[COCOEvaluator] Did not receive valid predictions.") + return {} + + logger.info("Preparing results for COCO format ...") + predictions = list( + itertools.chain(*[x["instances"] for x in predictions]) + ) # this is a list of dicts (see the conversion below) + + inputs = [] + images = {} + for x in predictions: + if x["image_id"] not in images: + in_ = self.dataset_dict[x["image_id"]] + for ann in in_["annotations"]: + ann["image_id"] = x["image_id"] + inputs.append(ann) + in_.pop("annotations") + in_["id"] = x["image_id"] + images[x["image_id"]] = in_ + logger.info(f"len(predictions): {len(predictions)} len(inputs): {len(inputs)}") + + self._results = OrderedDict() + if len(predictions) > 0: + self._results[self.iou_type] = self._eval_predictions(predictions, inputs, images) + # Copy so the caller can do whatever with results + return copy.deepcopy(self._results) + + def _eval_predictions(self, predictions, inputs, images): + """ + Evaluate predictions using COCO API. + + Args: + predictions: List of dicts containing model predictions + inputs: List of dicts containing ground truth annotations + """ + coco_inputs = inputs + coco_results = predictions + + assert self.dataset_dict.metadata.thing_classes is not None, "Metadata must contain thing_classes!" + categories = [ + { + "id": idx, + "name": x, + } + for idx, x in enumerate(self.dataset_dict.metadata.thing_classes) + ] + + coco_eval = ( + self._evaluate_predictions_on_coco( + images, + categories, + coco_inputs, + coco_results, + ) + if len(coco_results) > 0 + else None # cocoapi does not handle empty results very well + ) + + res = self._derive_coco_results(coco_eval, class_names=self.metadata.thing_classes) + return res + + def _evaluate_predictions_on_coco( + self, + images, + categories, + coco_gt, + coco_results, + ): + """ + Evaluate predictions using COCO evaluation API. + + Args: + images: List of dicts with image metadata + categories: List of dicts with category information + coco_gt: List of ground truth annotations in COCO format + coco_results: List of predictions in COCO format + + Returns: + COCOeval object with evaluation results + """ + assert len(coco_results) > 0 + # Basically, we have all the ingredients to do this. Before let's try with COCOeval + coco_gt = create_coco(images, categories, coco_gt, self.iou_type) + coco_dt = create_coco(images, categories, coco_results, self.iou_type) + + coco_eval = COCOeval(coco_gt, coco_dt, self.iou_type) # type: ignore + coco_eval.params.maxDets = [1, 10, 100] + coco_eval.evaluate() + coco_eval.accumulate() + coco_eval.summarize() + + return coco_eval + + def _derive_coco_results(self, coco_eval, class_names=None): + """ + Derive evaluation metrics from COCOeval results. + + Args: + coco_eval: COCOeval object containing evaluation results + class_names: List of category names for per-category metrics + + Returns: + dict: Results including: + - Overall metrics (AP, AP50, AP75, APs, APm, APl) + - Per-category AP if class_names provided + """ + iou_type = self.iou_type + metrics = { + "bbox": ["AP", "AP50", "AP75", "APs", "APm", "APl"], + "segm": ["AP", "AP50", "AP75", "APs", "APm", "APl"], + }[iou_type] + + if coco_eval is None: + logger.warn("No predictions from the model!") + return {metric: float("nan") for metric in metrics} + + # the standard metrics + results = { + metric: float(coco_eval.stats[idx] * 100 if coco_eval.stats[idx] >= 0 else "nan") + for idx, metric in enumerate(metrics) + } + logger.info("Evaluation results for {}: \n".format(iou_type) + create_small_table(results)) + if not np.isfinite(sum(results.values())): + logger.info("Some metrics cannot be computed and is shown as NaN.") + + if class_names is None or len(class_names) <= 1: + return results + # Compute per-category AP + # from https://github.com/facebookresearch/Detectron/blob/a6a835f5b8208c45d0dce217ce9bbda915f44df7/detectron/datasets/json_dataset_evaluator.py#L222-L252 # noqa + precisions = coco_eval.eval["precision"] + # precision has dims (iou, recall, cls, area range, max dets) + logger.info(f"precisions: {precisions.shape} class_names: {class_names}") + + assert len(class_names) == precisions.shape[2], ( + f"Found {len(class_names)} classes, but precision has dimension {precisions.shape[2]}" + ) + + results_per_category = [] + for idx, name in enumerate(class_names): + # area range index 0: all area ranges + # max dets index -1: typically 100 per image + precision = precisions[:, :, idx, 0, -1] + precision = precision[precision > -1] + ap = np.mean(precision) if precision.size else float("nan") + results_per_category.append(("{}".format(name), float(ap * 100))) + + # tabulate it + N_COLS = min(6, len(results_per_category) * 2) + results_flatten = list(itertools.chain(*results_per_category)) + results_2d = itertools.zip_longest(*[results_flatten[i::N_COLS] for i in range(N_COLS)]) + table = tabulate( + results_2d, + tablefmt="pipe", + floatfmt=".3f", + headers=["category", "AP"] * (N_COLS // 2), + numalign="left", + ) + logger.info("Per-category {} AP: \n".format(iou_type) + table) + + results.update({"AP-" + name: ap for name, ap in results_per_category}) + results = {k: (v if np.isfinite(v) else None) for k, v in results.items()} + return results + + def instances_to_coco_json(self, instances: Instances, img_id: int) -> List[dict]: + """ + Convert Instances predictions to COCO json format. + + Args: + instances: Instances object containing predictions + img_id: Image ID + + Returns: + list[dict]: List of detection/segmentation results in COCO format + """ + num_instance = len(instances) + if num_instance == 0: + return [] + + assert instances.boxes is not None, "Predictions must contain boxes!" + assert instances.scores is not None, "Predictions must contain scores!" + assert instances.classes is not None, "Predictions must contain classes!" + + boxes = instances.boxes.tensor.numpy() + boxes = BoxMode.convert(boxes, BoxMode.XYXY_ABS, BoxMode.XYWH_ABS) + boxes = boxes.tolist() # type: ignore + scores = instances.scores.tolist() + classes = instances.classes.tolist() + + if instances.masks is not None: + # use RLE to encode the masks, because they are too large and takes memory + # since this evaluator stores outputs of the entire dataset + rles = [ + mask_util.encode(np.array(mask[:, :, None], order="F", dtype="uint8"))[0] # type: ignore + for mask in instances.masks + ] + for rle in rles: + # "counts" is an array encoded by mask_util as a byte-stream. Python3's + # json writer which always produces strings cannot serialize a bytestream + # unless you decode it. Thankfully, utf-8 works out (which is also what + # the pycocotools/_mask.pyx does). + rle["counts"] = rle["counts"].decode("utf-8") + + if instances.keypoints is not None: + keypoints = instances.keypoints + + results = [] + for k in range(num_instance): + result = { + "image_id": img_id, + "category_id": classes[k], + "bbox": boxes[k], + "score": scores[k], + } + if instances.masks is not None: + result["segmentation"] = rles[k] + if instances.keypoints is not None: + # In COCO annotations, + # keypoints coordinates are pixel indices. + # However our predictions are floating point coordinates. + # Therefore we subtract 0.5 to be consistent with the annotation format. + # This is the inverse of data loading logic in `datasets/coco.py`. + keypoints[k][:, :2] -= 0.5 + result["keypoints"] = keypoints[k].flatten().tolist() + results.append(result) + return results + + +class InstanceSegmentationEvaluator(DetectionEvaluator): + """Evaluator for instance segmentation predictions.""" + + def __init__(self, dataset_dict: DictDataset, distributed=True): + super().__init__( + dataset_dict, + task="segm", + distributed=distributed, + ) + + +def create_coco(images, categories, coco_dict, iou_type): + """ + Create COCO API object from detection/segmentation data. + + Args: + images: List of dicts with image metadata + categories: List of dicts with category information + coco_dict: List of annotations/predictions in COCO format + iou_type: Type of evaluation - "bbox" or "segm" + + Returns: + COCO: COCO API object containing the data + """ + res = COCO() + res.dataset["images"] = images.values() + res.dataset["categories"] = categories + + anns = coco_dict + + if iou_type == "bbox": + res.dataset["categories"] = copy.deepcopy(res.dataset["categories"]) + for id, ann in enumerate(anns): + bb = ann["bbox"] + ann["area"] = bb[2] * bb[3] if "area" not in ann else ann["area"] + ann["id"] = id + 1 + ann["iscrowd"] = 0 if "iscrowd" not in ann else ann["iscrowd"] + elif iou_type == "segm": + res.dataset["categories"] = copy.deepcopy(res.dataset["categories"]) + for id, ann in enumerate(anns): + # now only support compressed RLE format as segmentation results + if isinstance(ann["segmentation"], list): + rles = mask_util.frPyObjects( + ann["segmentation"], + images[ann["image_id"]]["height"], + images[ann["image_id"]]["width"], + ) + rle = mask_util.merge(rles) + elif isinstance(ann["segmentation"]["counts"], list): + rle = mask_util.frPyObjects( + ann["segmentation"], + images[ann["image_id"]]["height"], + images[ann["image_id"]]["width"], + ) + else: + rle = ann["segmentation"] + ann["area"] = mask_util.area(rle) if "area" not in ann else ann["area"] + if "bbox" not in ann: + ann["bbox"] = mask_util.toBbox(rle) + ann["id"] = id + 1 + ann["iscrowd"] = 0 if "iscrowd" not in ann else ann["iscrowd"] + + res.dataset["annotations"] = anns + res.createIndex() + return res diff --git a/focoos/trainer/evaluation/evaluator.py b/focoos/trainer/evaluation/evaluator.py new file mode 100644 index 00000000..c2745f64 --- /dev/null +++ b/focoos/trainer/evaluation/evaluator.py @@ -0,0 +1,251 @@ +# Copyright (c) Facebook, Inc. and its affiliates. +import datetime +import logging +import time +from collections import OrderedDict, abc +from contextlib import ExitStack, contextmanager +from typing import List, Union + +import torch +from torch import nn + +from focoos.processor.base_processor import Processor +from focoos.utils.distributed.comm import get_world_size, is_main_process +from focoos.utils.logger import get_logger, log_every_n_seconds + +logger = get_logger(__name__) + + +class DatasetEvaluator: + """ + Base class for a dataset evaluator. + + The function :func:`inference_on_dataset` runs the model over + all samples in the dataset, and have a DatasetEvaluator to process the inputs/outputs. + + This class will accumulate information of the inputs/outputs (by :meth:`process`), + and produce evaluation results in the end (by :meth:`evaluate`). + """ + + def __init__(self, dataset_dict, **kwargs): + self.dataset_dict = dataset_dict + + @classmethod + def from_datasetdict(cls, dataset_dict, **kwargs): + return cls(dataset_dict, **kwargs) + + def __repr__(self): + return f"{self.__class__.__name__}(dataset_dict={self.dataset_dict})" + + def reset(self): + """ + Preparation for a new round of evaluation. + Should be called before starting a round of evaluation. + """ + pass + + def process(self, inputs, outputs): + """ + Process the pair of inputs and outputs. + If they contain batches, the pairs can be consumed one-by-one using `zip`: + + .. code-block:: python + + for input_, output in zip(inputs, outputs): + # do evaluation on single input/output pair + ... + + Args: + inputs (list): the inputs that's used to call the model. + outputs (list): the return value of `model(inputs)` + """ + pass + + def evaluate(self): + """ + Evaluate/summarize the performance, after processing all input/output pairs. + + Returns: + dict: + A new evaluator class can return a dict of arbitrary format + as long as the user can process the results. + In our train_net.py, we expect the following format: + + * key: the name of the task (e.g., bbox) + * value: a dict of {metric name: score}, e.g.: {"AP50": 80} + """ + pass + + +class DatasetEvaluators(DatasetEvaluator): + """ + Wrapper class to combine multiple :class:`DatasetEvaluator` instances. + + This class dispatches every evaluation call to + all of its :class:`DatasetEvaluator`. + """ + + def __init__(self, evaluators): + """ + Args: + evaluators (list): the evaluators to combine. + """ + super().__init__() + self._evaluators = evaluators + + def reset(self): + for evaluator in self._evaluators: + evaluator.reset() + + def process(self, inputs, outputs): + for evaluator in self._evaluators: + evaluator.process(inputs, outputs) + + def evaluate(self): + results = OrderedDict() + for evaluator in self._evaluators: + result = evaluator.evaluate() + if is_main_process() and result is not None: + for k, v in result.items(): + assert k not in results, "Different evaluators produce results with the same key {}".format(k) + results[k] = v + return results + + +def inference_on_dataset( + model: nn.Module, + processor: Processor, + data_loader, + evaluator: Union[DatasetEvaluator, List[DatasetEvaluator], None], + callbacks=None, +): + """ + Run model on the data_loader and evaluate the metrics with evaluator. + Also benchmark the inference speed of `model.__call__` accurately. + The model will be used in eval mode. + + Args: + model (callable): a callable which takes an object from + `data_loader` and returns some outputs. + + If it's an nn.Module, it will be temporarily set to `eval` mode. + If you wish to evaluate a model in `training` mode instead, you can + wrap the given model and override its behavior of `.eval()` and `.train()`. + data_loader: an iterable object with a length. + The elements it generates will be the inputs to the model. + evaluator: the evaluator(s) to run. Use `None` if you only want to benchmark, + but don't want to do any evaluation. + callbacks (dict of callables): a dictionary of callback functions which can be + called at each stage of inference. + + Returns: + The return value of `evaluator.evaluate()` + """ + num_devices = get_world_size() + logger.info(f"Start inference on {len(data_loader)} batches") + + total = len(data_loader) # inference data loader must have a fixed length + if evaluator is None: + # create a no-op evaluator + evaluator = DatasetEvaluators([]) + if isinstance(evaluator, abc.MutableSequence): + evaluator = DatasetEvaluators(evaluator) + evaluator.reset() + + num_warmup = min(5, total - 1) + start_time = time.perf_counter() + total_data_time = 0 + total_compute_time = 0 + total_eval_time = 0 + with ExitStack() as stack: + if isinstance(model, nn.Module): + stack.enter_context(inference_context(model)) + stack.enter_context(inference_context(processor)) + stack.enter_context(torch.no_grad()) + + start_data_time = time.perf_counter() + dict.get(callbacks or {}, "on_start", lambda: None)() + for idx, inputs in enumerate(data_loader): + total_data_time += time.perf_counter() - start_data_time + if idx == num_warmup: + start_time = time.perf_counter() + total_data_time = 0 + total_compute_time = 0 + total_eval_time = 0 + + start_compute_time = time.perf_counter() + dict.get(callbacks or {}, "before_inference", lambda: None)() + images, _ = processor.preprocess(inputs, device=model.device, dtype=model.dtype) + outputs = model(images) + outputs = processor.eval_postprocess(outputs, inputs) + dict.get(callbacks or {}, "after_inference", lambda: None)() + if torch.cuda.is_available(): + torch.cuda.synchronize() + total_compute_time += time.perf_counter() - start_compute_time + + start_eval_time = time.perf_counter() + evaluator.process(inputs, outputs) + total_eval_time += time.perf_counter() - start_eval_time + + iters_after_start = idx + 1 - num_warmup * int(idx >= num_warmup) + data_seconds_per_iter = total_data_time / iters_after_start + compute_seconds_per_iter = total_compute_time / iters_after_start + eval_seconds_per_iter = total_eval_time / iters_after_start + total_seconds_per_iter = (time.perf_counter() - start_time) / iters_after_start + if idx >= num_warmup * 2 or compute_seconds_per_iter > 5: + eta = datetime.timedelta(seconds=int(total_seconds_per_iter * (total - idx - 1))) + log_every_n_seconds( + logging.INFO, + ( + f"Inference done {idx + 1}/{total}. " + f"Dataloading: {data_seconds_per_iter:.4f} s/iter. " + f"Inference: {compute_seconds_per_iter:.4f} s/iter. " + f"Eval: {eval_seconds_per_iter:.4f} s/iter. " + f"Total: {total_seconds_per_iter:.4f} s/iter. " + f"ETA={eta}" + ), + n=5, + name="trainer", + ) + start_data_time = time.perf_counter() + dict.get(callbacks or {}, "on_end", lambda: None)() + + # Measure the time only for this worker (before the synchronization barrier) + total_time = time.perf_counter() - start_time + total_time_str = str(datetime.timedelta(seconds=total_time)) + # NOTE this format is parsed by grep + logger.info( + "Total inference time: {} ({:.6f} s / iter per device, on {} devices)".format( + total_time_str, total_time / (total - num_warmup), num_devices + ) + ) + total_compute_time_str = str(datetime.timedelta(seconds=int(total_compute_time))) + logger.info( + "Total inference pure compute time: {} ({:.6f} s / iter per device, on {} devices)".format( + total_compute_time_str, + total_compute_time / (total - num_warmup), + num_devices, + ) + ) + + results = evaluator.evaluate() + # An evaluator may return None when not in main process. + # Replace it by an empty dict instead to make it easier for downstream code to handle + if results is None: + results = {} + return results + + +@contextmanager +def inference_context(model): + """ + A context where the model is temporarily changed to eval mode, + and restored to previous mode afterwards. + + Args: + model: a torch Module + """ + training_mode = model.training + model.eval() + yield + model.train(training_mode) diff --git a/focoos/trainer/evaluation/get_eval.py b/focoos/trainer/evaluation/get_eval.py new file mode 100644 index 00000000..413306be --- /dev/null +++ b/focoos/trainer/evaluation/get_eval.py @@ -0,0 +1,26 @@ +from focoos.data.datasets.dict_dataset import DictDataset +from focoos.ports import Task + + +def get_evaluator(dataset_dict: DictDataset, task: Task): + if task == Task.DETECTION: + from focoos.trainer.evaluation.detection_evaluation import DetectionEvaluator + + return DetectionEvaluator.from_datasetdict(dataset_dict=dataset_dict) + elif task == Task.INSTANCE_SEGMENTATION: + from focoos.trainer.evaluation.detection_evaluation import InstanceSegmentationEvaluator + + return InstanceSegmentationEvaluator.from_datasetdict(dataset_dict=dataset_dict) + elif task == Task.SEMSEG: + from focoos.trainer.evaluation.sem_seg_evaluation import SemSegEvaluator + + return SemSegEvaluator.from_datasetdict(dataset_dict=dataset_dict) + elif task == Task.CLASSIFICATION: + from focoos.trainer.evaluation.classification_evaluation import ClassificationEvaluator + + return ClassificationEvaluator.from_datasetdict(dataset_dict=dataset_dict) + # elif task == Task.PANOPTIC_SEGMENTATION: + # from focoos.trainer.evaluation.panoptic_evaluation import PanopticEvaluator + # return PanopticEvaluator.from_datasetdict(dataset_dict=dataset_dict) + else: + raise ValueError(f"Task {task} not supported") diff --git a/focoos/trainer/evaluation/panoptic_evaluation.py b/focoos/trainer/evaluation/panoptic_evaluation.py new file mode 100644 index 00000000..65c68808 --- /dev/null +++ b/focoos/trainer/evaluation/panoptic_evaluation.py @@ -0,0 +1,278 @@ +# Copyright (c) Facebook, Inc. and its affiliates. +import contextlib +import io +import itertools +import json +import os +import tempfile +from collections import OrderedDict +from typing import Optional + +import numpy as np +from PIL import Image +from tabulate import tabulate + +from focoos.data.datasets.dict_dataset import DictDataset +from focoos.utils.distributed import comm +from focoos.utils.logger import get_logger + +from .evaluator import DatasetEvaluator + +logger = get_logger(__name__) + + +class COCOPanopticEvaluator(DatasetEvaluator): + """ + Evaluate Panoptic Quality metrics on COCO using PanopticAPI. + It saves panoptic segmentation prediction in `output_dir` + + It contains a synchronize call and has to be called from all workers. + """ + + def __init__(self, dataset_dict: DictDataset, output_dir: Optional[str] = None): + """ + Args: + dataset_name: name of the dataset + output_dir: output directory to save results for evaluation. + """ + self.dataset_dict = dataset_dict + self._metadata = self.dataset_dict.metadata + self._thing_contiguous_id_to_dataset_id = { + v: k for k, v in self._metadata.thing_dataset_id_to_contiguous_id.items() + } + self._stuff_contiguous_id_to_dataset_id = { + v: k for k, v in self._metadata.stuff_dataset_id_to_contiguous_id.items() + } + + def reset(self): + self._predictions = [] + + def _convert_category_id(self, segment_info): + isthing = segment_info.pop("isthing", None) + if isthing is None: + # the model produces panoptic category id directly. No more conversion needed + return segment_info + if isthing is True: + segment_info["category_id"] = self._thing_contiguous_id_to_dataset_id[segment_info["category_id"]] + else: + segment_info["category_id"] = self._stuff_contiguous_id_to_dataset_id[segment_info["category_id"]] + return segment_info + + def process(self, inputs, outputs): + from panopticapi.utils import id2rgb + + for input, output in zip(inputs, outputs): + panoptic_img, segments_info = output["panoptic_seg"] + panoptic_img = panoptic_img.cpu().numpy() + if segments_info is None: + # If "segments_info" is None, we assume "panoptic_img" is a + # H*W int32 image storing the panoptic_id in the format of + # category_id * label_divisor + instance_id. We reserve -1 for + # VOID label, and add 1 to panoptic_img since the official + # evaluation script uses 0 for VOID label. + label_divisor = self._metadata.label_divisor + segments_info = [] + for panoptic_label in np.unique(panoptic_img): + if panoptic_label == -1: + # VOID region. + continue + pred_class = panoptic_label // label_divisor + isthing = pred_class in self._metadata.thing_dataset_id_to_contiguous_id.values() + segments_info.append( + { + "id": int(panoptic_label) + 1, + "category_id": int(pred_class), + "isthing": bool(isthing), + } + ) + # Official evaluation script uses 0 for VOID label. + panoptic_img += 1 + + file_name = os.path.basename(input["file_name"]) + file_name_png = os.path.splitext(file_name)[0] + ".png" + with io.BytesIO() as out: + Image.fromarray(id2rgb(panoptic_img)).save(out, format="PNG") + segments_info = [self._convert_category_id(x) for x in segments_info] + self._predictions.append( + { + "image_id": input["image_id"], + "file_name": file_name_png, + "png_string": out.getvalue(), + "segments_info": segments_info, + } + ) + + def evaluate(self): + comm.synchronize() + + self._predictions = comm.gather(self._predictions) + self._predictions = list(itertools.chain(*self._predictions)) + if not comm.is_main_process(): + return + + # PanopticApi requires local files + gt_json = self._metadata.json_file + gt_folder = self._metadata.panoptic_root + + with tempfile.TemporaryDirectory(prefix="panoptic_eval") as pred_dir: + logger.info("Writing all panoptic predictions to {} ...".format(pred_dir)) + for p in self._predictions: + with open(os.path.join(pred_dir, p["file_name"]), "wb") as f: + f.write(p.pop("png_string")) + + with open(gt_json) as f: + json_data = json.load(f) + json_data["annotations"] = self._predictions + + output_dir = pred_dir + predictions_json = os.path.join(output_dir, "predictions.json") + with open(predictions_json, "w") as f: + f.write(json.dumps(json_data)) + + from panopticapi.evaluation import pq_compute + + with contextlib.redirect_stdout(io.StringIO()): + pq_res = pq_compute( + gt_json, + predictions_json, + gt_folder=gt_folder, + pred_folder=pred_dir, + ) + + res = {} + res["PQ"] = 100 * pq_res["All"]["pq"] + res["SQ"] = 100 * pq_res["All"]["sq"] + res["RQ"] = 100 * pq_res["All"]["rq"] + res["PQ_th"] = 100 * pq_res["Things"]["pq"] + res["SQ_th"] = 100 * pq_res["Things"]["sq"] + res["RQ_th"] = 100 * pq_res["Things"]["rq"] + res["PQ_st"] = 100 * pq_res["Stuff"]["pq"] + res["SQ_st"] = 100 * pq_res["Stuff"]["sq"] + res["RQ_st"] = 100 * pq_res["Stuff"]["rq"] + + results = OrderedDict({"panoptic_seg": res}) + _print_panoptic_results(pq_res) + + return results + + +def _print_panoptic_results(pq_res): + headers = ["", "PQ", "SQ", "RQ", "#categories"] + data = [] + for name in ["All", "Things", "Stuff"]: + row = [name] + [pq_res[name][k] * 100 for k in ["pq", "sq", "rq"]] + [pq_res[name]["n"]] + data.append(row) + table = tabulate( + data, + headers=headers, + tablefmt="pipe", + floatfmt=".3f", + stralign="center", + numalign="center", + ) + logger.info("Panoptic Evaluation Results:\n" + table) + + +class PanopticEvaluator(COCOPanopticEvaluator): + def __init__(self, metadata, output_dir: Optional[str] = None): + super().__init__(metadata, output_dir) + + def evaluate(self): + comm.synchronize() + + self._predictions = comm.gather(self._predictions) + self._predictions = list(itertools.chain(*self._predictions)) + if not comm.is_main_process(): + return + + # PanopticApi requires local files + gt_json = self._metadata.json_file + gt_folder = self._metadata.panoptic_root + + with tempfile.TemporaryDirectory(prefix="panoptic_eval") as pred_dir: + logger.info("Writing all panoptic predictions to {} ...".format(pred_dir)) + for p in self._predictions: + with open(os.path.join(pred_dir, p["file_name"]), "wb") as f: + f.write(p.pop("png_string")) + + with open(gt_json) as f: + json_data = json.load(f) + json_data["annotations"] = self._predictions + + output_dir = pred_dir + predictions_json = os.path.join(output_dir, "predictions.json") + with open(predictions_json, "w") as f: + f.write(json.dumps(json_data)) + + from panopticapi.evaluation import pq_compute + + with contextlib.redirect_stdout(io.StringIO()): + pq_res = pq_compute( + gt_json, + predictions_json, + gt_folder=gt_folder, + pred_folder=pred_dir, + ) + + res = {} + res["PQ"] = 100 * pq_res["All"]["pq"] + res["SQ"] = 100 * pq_res["All"]["sq"] + res["RQ"] = 100 * pq_res["All"]["rq"] + res["PQ_th"] = 100 * pq_res["Things"]["pq"] + res["SQ_th"] = 100 * pq_res["Things"]["sq"] + res["RQ_th"] = 100 * pq_res["Things"]["rq"] + res["PQ_st"] = 100 * pq_res["Stuff"]["pq"] + res["SQ_st"] = 100 * pq_res["Stuff"]["sq"] + res["RQ_st"] = 100 * pq_res["Stuff"]["rq"] + + results = OrderedDict({"panoptic_seg": res}) + _print_panoptic_results(pq_res, json_data) + + return results + + +def id2name(json_data, id): + for c in json_data["categories"]: + if c["id"] == id: + return c["name"] + + raise KeyError + + +def _print_panoptic_results(pq_res, json_data): + headers = ["", "PQ", "SQ", "RQ", "#categories"] + data = [] + for name in ["All", "Things", "Stuff"]: + row = [name] + [pq_res[name][k] * 100 for k in ["pq", "sq", "rq"]] + [pq_res[name]["n"]] + data.append(row) + table = tabulate( + data, + headers=headers, + tablefmt="pipe", + floatfmt=".3f", + stralign="center", + numalign="center", + ) + logger.info("General Panoptic Evaluation Results:\n" + table) + + # id2name = {} + # for c in gt_json['categories']: + # id2name[c['id']] = c['name'] + headers = ["category", "PQ", "SQ", "RQ"] + data = [] + for category, result in pq_res["per_class"].items(): + try: + name = id2name(json_data, category) + row = [name] + [result[k] * 100 for k in ["pq", "sq", "rq"]] + except KeyError: + row = [category] + [result[k] * 100 for k in ["pq", "sq", "rq"]] + data.append(row) + table = tabulate( + data, + headers=headers, + tablefmt="pipe", + floatfmt=".3f", + stralign="left", + numalign="center", + ) + logger.info("Per-class Panoptic Evaluation Results:\n" + table) diff --git a/focoos/trainer/evaluation/sem_seg_evaluation.py b/focoos/trainer/evaluation/sem_seg_evaluation.py new file mode 100644 index 00000000..2218b717 --- /dev/null +++ b/focoos/trainer/evaluation/sem_seg_evaluation.py @@ -0,0 +1,188 @@ +# Copyright (c) Facebook, Inc. and its affiliates. +import itertools +from collections import OrderedDict +from typing import Optional, Union + +import numpy as np +import pycocotools.mask as mask_util +import torch +from PIL import Image + +from focoos.data.datasets.dict_dataset import DictDataset +from focoos.data.mappers.semantic_dataset_mapper import SemanticSegmentationDatasetEntry +from focoos.trainer.evaluation.evaluator import DatasetEvaluator +from focoos.utils.distributed.comm import all_gather, is_main_process, synchronize +from focoos.utils.logger import get_logger + +_CV2_IMPORTED = True +try: + import cv2 # noqa +except ImportError: + # OpenCV is an optional dependency at the moment + _CV2_IMPORTED = False + + +logger = get_logger(__name__) + + +def load_image_into_numpy_array( + filename: str, + dtype: Optional[Union[np.dtype, str]] = None, +) -> np.ndarray: + with open(filename, "rb") as f: + array = np.array(Image.open(f), dtype=dtype) + return array + + +class SemSegEvaluator(DatasetEvaluator): + """ + Evaluate semantic segmentation metrics. + """ + + def __init__( + self, + dataset_dict: DictDataset, + distributed=True, + output_dir=None, + *, + sem_seg_loading_fn=load_image_into_numpy_array, + ): + """ + Args:s + dataset_name (str): name of the dataset to be evaluated. + distributed (bool): if True, will collect results from all ranks for evaluation. + Otherwise, will evaluate the results in the current process. + output_dir (str): an output directory to dump results. + sem_seg_loading_fn: function to read sem seg file and load into numpy array. + Default provided, but projects can customize. + """ + self.dataset_dict = dataset_dict + self.metadata = self.dataset_dict.metadata + + self._dataset_name = self.metadata.name + self._distributed = distributed + self._output_dir = output_dir + self._cpu_device = torch.device("cpu") + + self._ignore_label = self.metadata.ignore_label + if self.metadata.stuff_classes is None: + raise ValueError("stuff_classes is None") + self._num_classes = len(self.metadata.stuff_classes) + + # Dict that maps contiguous training ids to COCO category ids + try: + c2d = self.metadata.stuff_dataset_id_to_contiguous_id + self._contiguous_id_to_dataset_id = {v: k for k, v in c2d.items()} + except AttributeError: + self._contiguous_id_to_dataset_id = None + self._class_names = self.metadata.stuff_classes + self.sem_seg_loading_fn = sem_seg_loading_fn + self._num_classes = len(self.metadata.stuff_classes) + + def reset(self): + self._conf_matrix = np.zeros((self._num_classes + 1, self._num_classes + 1), dtype=np.int64) + self._predictions = [] + + def process(self, inputs: list[SemanticSegmentationDatasetEntry], outputs: list[dict[str, torch.Tensor]]): + """ + Args: + inputs: the inputs to a model. + It is a list of dicts. Each dict corresponds to an image and + contains keys like "height", "width", "file_name". + outputs: the outputs of a model. It is either list of semantic segmentation predictions + (Tensor [H, W]) or list of dicts with key "sem_seg" that contains semantic + segmentation prediction in the same format. + """ + for input, output in zip(inputs, outputs): + output = output["sem_seg"].argmax(dim=0).to(self._cpu_device) + pred = np.array(output, dtype=int) + gt_filename = input.sem_seg_file_name + gt = self.sem_seg_loading_fn(gt_filename, dtype=int) + + gt[gt == self._ignore_label] = self._num_classes + + self._conf_matrix += np.bincount( + (self._num_classes + 1) * pred.reshape(-1) + gt.reshape(-1), + minlength=self._conf_matrix.size, + ).reshape(self._conf_matrix.shape) + + self._predictions.extend(self.encode_json_sem_seg(pred, input.file_name)) + + def evaluate(self): + """ + Evaluates standard semantic segmentation metrics (http://cocodataset.org/#stuff-eval): + + * Mean intersection-over-union averaged across classes (mIoU) + * Frequency Weighted IoU (fwIoU) + * Mean pixel accuracy averaged across classes (mACC) + * Pixel Accuracy (pACC) + """ + if self._distributed: + synchronize() + conf_matrix_list = all_gather(self._conf_matrix) + self._predictions = all_gather(self._predictions) + self._predictions = list(itertools.chain(*self._predictions)) + if not is_main_process(): + return + + self._conf_matrix = np.zeros_like(self._conf_matrix) + for conf_matrix in conf_matrix_list: + self._conf_matrix += conf_matrix + + acc = np.full(self._num_classes, np.nan, dtype=float) + iou = np.full(self._num_classes, np.nan, dtype=float) + tp = self._conf_matrix.diagonal()[:-1].astype(float) + pos_gt = np.sum(self._conf_matrix[:-1, :-1], axis=0).astype(float) + class_weights = pos_gt / np.sum(pos_gt) + pos_pred = np.sum(self._conf_matrix[:-1, :-1], axis=1).astype(float) + acc_valid = pos_gt > 0 + acc[acc_valid] = tp[acc_valid] / pos_gt[acc_valid] + union = pos_gt + pos_pred - tp + iou_valid = np.logical_and(acc_valid, union > 0) + iou[iou_valid] = tp[iou_valid] / union[iou_valid] + macc = np.sum(acc[acc_valid]) / np.sum(acc_valid) + miou = np.sum(iou[iou_valid]) / np.sum(iou_valid) + fiou = np.sum(iou[iou_valid] * class_weights[iou_valid]) + pacc = np.sum(tp) / np.sum(pos_gt) + + res = {} + res["mIoU"] = 100 * miou + res["fwIoU"] = 100 * fiou + for i, name in enumerate(self._class_names): + res[f"IoU-{name}"] = 100 * iou[i] + res["mACC"] = 100 * macc + res["pACC"] = 100 * pacc + for i, name in enumerate(self._class_names): + res[f"ACC-{name}"] = 100 * acc[i] + + res = {k: (v if np.isfinite(v) else None) for k, v in res.items()} + results = OrderedDict({"sem_seg": res}) + + logger.info(results) + return results + + def encode_json_sem_seg(self, sem_seg, input_file_name): + """ + Convert semantic segmentation to COCO stuff format with segments encoded as RLEs. + See http://cocodataset.org/#format-results + """ + json_list = [] + for label in np.unique(sem_seg): + if self._contiguous_id_to_dataset_id is not None: + assert label in self._contiguous_id_to_dataset_id, "Label {} is not in the metadata info for {}".format( + label, self._dataset_name + ) + dataset_id = self._contiguous_id_to_dataset_id[label] + else: + dataset_id = int(label) + mask = (sem_seg == label).astype(np.uint8) + mask_rle = mask_util.encode(np.array(mask[:, :, None], order="F"))[0] + mask_rle["counts"] = mask_rle["counts"].decode("utf-8") + json_list.append( + { + "file_name": input_file_name, + "category_id": dataset_id, + "segmentation": mask_rle, + } + ) + return json_list diff --git a/focoos/trainer/evaluation/utils.py b/focoos/trainer/evaluation/utils.py new file mode 100644 index 00000000..cd9712bf --- /dev/null +++ b/focoos/trainer/evaluation/utils.py @@ -0,0 +1,27 @@ +# Copyright (c) Facebook, Inc. and its affiliates. +from collections.abc import Mapping + +from focoos.utils.logger import get_logger + +logger = get_logger(__name__) + + +def print_csv_format(results): + """ + Print main metrics in a format similar to Detectron, + so that they are easy to copypaste into a spreadsheet. + + Args: + results (OrderedDict[dict]): task_name -> {metric -> score} + unordered dict can also be printed, but in arbitrary order + """ + assert isinstance(results, Mapping) or not len(results), results + for task, res in results.items(): + if isinstance(res, Mapping): + # Don't print "AP-category" metrics since they are usually not tracked. + important_res = [(k, v) for k, v in res.items() if "-" not in k] + logger.info("copypaste: Task: {}".format(task)) + logger.info("copypaste: " + ",".join([k[0] for k in important_res])) + logger.info("copypaste: " + ",".join([f"{(k[1] if k[1] is not None else -1):.4f}" for k in important_res])) + else: + logger.info(f"copypaste: {task}={res}") diff --git a/focoos/trainer/events.py b/focoos/trainer/events.py new file mode 100644 index 00000000..c42b47af --- /dev/null +++ b/focoos/trainer/events.py @@ -0,0 +1,613 @@ +# Copyright (c) FocoosAI +# Part of the code has been copied and adapted from Detectron2 (c) Facebook, Inc. and its affiliates. +import datetime +import json +import os +import time +from collections import defaultdict +from contextlib import contextmanager +from functools import cached_property +from typing import List, Optional, Tuple + +import numpy as np +import torch + +from focoos.utils.logger import get_logger + +__all__ = [ + "get_event_storage", + "has_event_storage", + "JSONWriter", + "TensorboardXWriter", + "CommonMetricPrinter", + "EventStorage", +] + +_CURRENT_STORAGE_STACK = [] + + +logger = get_logger(__name__) + + +class HistoryBuffer: + """ + Track a series of scalar values and provide access to smoothed values over a + window or the global average of the series. + """ + + def __init__(self, max_length: int = 1000000) -> None: + """ + Args: + max_length: maximal number of values that can be stored in the + buffer. When the capacity of the buffer is exhausted, old + values will be removed. + """ + self._max_length: int = max_length + self._data: List[Tuple[float, float]] = [] # (value, iteration) pairs + self._count: int = 0 + self._global_avg: float = 0 + + def update(self, value: float, iteration: Optional[float] = None) -> None: + """ + Add a new scalar value produced at certain iteration. If the length + of the buffer exceeds self._max_length, the oldest element will be + removed from the buffer. + """ + if iteration is None: + iteration = self._count + if len(self._data) == self._max_length: + self._data.pop(0) + self._data.append((value, iteration)) + + self._count += 1 + self._global_avg += (value - self._global_avg) / self._count + + def latest(self) -> float: + """ + Return the latest scalar value added to the buffer. + """ + return self._data[-1][0] + + def median(self, window_size: int) -> float: + """ + Return the median of the latest `window_size` values in the buffer. + """ + return np.median([x[0] for x in self._data[-window_size:]]) # type: ignore + + def avg(self, window_size: int) -> float: + """ + Return the mean of the latest `window_size` values in the buffer. + """ + return np.mean([x[0] for x in self._data[-window_size:]]) # type: ignore + + def global_avg(self) -> float: + """ + Return the mean of all the elements in the buffer. Note that this + includes those getting removed due to limited buffer storage. + """ + return self._global_avg + + def values(self) -> List[Tuple[float, float]]: + """ + Returns: + list[(number, iteration)]: content of the current buffer. + """ + return self._data + + +def get_event_storage(): + """ + Returns: + The :class:`EventStorage` object that's currently being used. + Throws an error if no :class:`EventStorage` is currently enabled. + """ + assert len(_CURRENT_STORAGE_STACK), ( + "get_event_storage() has to be called inside a 'with EventStorage(...)' context!" + ) + return _CURRENT_STORAGE_STACK[-1] + + +def has_event_storage(): + """ + Returns: + Check if there are EventStorage() context existed. + """ + return len(_CURRENT_STORAGE_STACK) > 0 + + +class EventWriter: + """ + Base class for writers that obtain events from :class:`EventStorage` and process them. + """ + + def write(self): + raise NotImplementedError + + def close(self): + pass + + +class JSONWriter(EventWriter): + """ + Write scalars to a json file as a single JSON array. + + Example structure of the resulting file: + [ + {"iteration": 1, "loss": 1.0, ...}, + {"iteration": 2, "loss": 0.9, ...} + ] + """ + + def __init__(self, json_file, window_size=20): + """ + Args: + json_file (str): path to the json file. New data will be inserted before the final "]". + If the file doesn't exist, it will be created. + window_size (int): the window size of median smoothing for the scalars whose + `smoothing_hint` are True. + force_close (bool): whether to close the file after each write operation. + """ + self._json_file = json_file + self._window_size = window_size + self._last_write = -1 + + # Initialize the file if it doesn't exist + if not os.path.exists(json_file): + with open(json_file, "w") as f: + f.write("[\n]") + + def write(self): + storage = get_event_storage() + to_save = defaultdict(dict) + + # Get latest metrics with smoothing applied based on window_size + for k, (v, iter) in storage.latest_with_smoothing_hint(self._window_size).items(): + if iter <= self._last_write: + continue + to_save[iter][k] = v + + # If we have new metrics to save + if len(to_save): + all_iters = sorted(to_save.keys()) + self._last_write = max(all_iters) + + # Open file in read+ mode + with open(self._json_file, "r+") as f: + # Read file contents + f.seek(0, os.SEEK_END) + file_size = f.tell() + + # Empty file or improperly formatted + if file_size <= 2: + f.seek(0) + f.write("[\n]") + file_size = 3 + + # Move cursor before the final bracket + f.seek(file_size - 3) + + # Check if we need to add a comma (not empty array) + last_char = f.read(1) + needs_comma = last_char != "[" + + # Go back to position before closing bracket + f.seek(file_size - 2) + + # Write each metric object + for itr, scalars_per_iter in to_save.items(): + scalars_per_iter["iteration"] = itr + json_str = json.dumps(scalars_per_iter, sort_keys=True) + + if needs_comma: + f.write(",\n" + json_str) + else: + f.write(json_str) + needs_comma = True + + # Write closing bracket + f.write("\n]") + + # Ensure file is flushed to disk + f.flush() + try: + os.fsync(f.fileno()) + except AttributeError: + logger.warning("File handle doesn't support fsync") + + def close(self): + pass + + +class TensorboardXWriter(EventWriter): + """ + Write all scalars to a tensorboard file. + """ + + def __init__(self, log_dir: str, window_size: int = 20, **kwargs): + """ + Args: + log_dir (str): the directory to save the output events + window_size (int): the scalars will be median-smoothed by this window size + + kwargs: other arguments passed to `torch.utils.tensorboard.SummaryWriter(...)` + """ + self._window_size = window_size + self._writer_args = {"log_dir": log_dir, **kwargs} + self._last_write = -1 + + @cached_property + def _writer(self): + from torch.utils.tensorboard import SummaryWriter + + return SummaryWriter(**self._writer_args) + + def write(self): + storage = get_event_storage() + new_last_write = self._last_write + for k, (v, iter) in storage.latest_with_smoothing_hint(self._window_size).items(): + if iter > self._last_write: + self._writer.add_scalar(k, v, iter) + new_last_write = max(new_last_write, iter) + self._last_write = new_last_write + + # storage.put_{image,histogram} is only meant to be used by + # tensorboard writer. So we access its internal fields directly from here. + if len(storage._vis_data) >= 1: + for img_name, img, step_num in storage._vis_data: + self._writer.add_image(img_name, img, step_num) + # Storage stores all image data and rely on this writer to clear them. + # As a result it assumes only one writer will use its image data. + # An alternative design is to let storage store limited recent + # data (e.g. only the most recent image) that all writers can access. + # In that case a writer may not see all image data if its period is long. + storage.clear_images() + + if len(storage._histograms) >= 1: + for params in storage._histograms: + self._writer.add_histogram_raw(**params) + storage.clear_histograms() + + def close(self): + if "_writer" in self.__dict__: + self._writer.close() + + +class CommonMetricPrinter(EventWriter): + """ + Print **common** metrics to the terminal, including + iteration time, ETA, memory, all losses, and the learning rate. + It also applies smoothing using a window of 20 elements. + + It's meant to print common metrics in common ways. + To print something in more customized ways, please implement a similar printer by yourself. + """ + + def __init__(self, max_iter: Optional[int] = None, window_size: int = 20): + """ + Args: + max_iter: the maximum number of iterations to train. + Used to compute ETA. If not given, ETA will not be printed. + window_size (int): the losses will be median-smoothed by this window size + """ + + self._max_iter = max_iter + self._window_size = window_size + self._last_write = None # (step, time) of last call to write(). Used to compute ETA + + def _get_eta(self, storage) -> Optional[str]: + if self._max_iter is None: + return "" + iteration = storage.iter + try: + eta_seconds = storage.history("time").median(1000) * (self._max_iter - iteration - 1) + storage.put_scalar("eta_seconds", eta_seconds, smoothing_hint=False) + return str(datetime.timedelta(seconds=int(eta_seconds))) + except KeyError: + # estimate eta on our own - more noisy + eta_string = None + if self._last_write is not None: + estimate_iter_time = (time.perf_counter() - self._last_write[1]) / (iteration - self._last_write[0]) + eta_seconds = estimate_iter_time * (self._max_iter - iteration - 1) + eta_string = str(datetime.timedelta(seconds=int(eta_seconds))) + self._last_write = (iteration, time.perf_counter()) + return eta_string + + def write(self): + storage = get_event_storage() + iteration = storage.iter + if iteration == self._max_iter: + # This hook only reports training progress (loss, ETA, etc) but not other data, + # therefore do not write anything after training succeeds, even if this method + # is called. + return + + try: + avg_data_time = storage.history("data_time").avg(storage.count_samples("data_time", self._window_size)) + last_data_time = storage.history("data_time").latest() + except KeyError: + # they may not exist in the first few iterations (due to warmup) + # or when SimpleTrainer is not used + avg_data_time = None + last_data_time = None + try: + avg_iter_time = storage.history("time").global_avg() + last_iter_time = storage.history("time").latest() + except KeyError: + avg_iter_time = None + last_iter_time = None + try: + lr = "{:.5g}".format(storage.history("lr").latest()) + except KeyError: + lr = "N/A" + + eta_string = self._get_eta(storage) + + if torch.cuda.is_available(): + max_mem_mb = torch.cuda.max_memory_allocated() / 1024.0 / 1024.0 + else: + max_mem_mb = None + + # NOTE: max_mem is parsed by grep in "dev/parse_results.sh" + logger.info( + str.format( + " {eta}iter: {iter} {losses} {non_losses} {avg_time}{last_time}" + + "{avg_data_time}{last_data_time} lr: {lr} {memory}", + eta=f"eta: {eta_string} " if eta_string else "", + iter=iteration, + losses=" ".join( + [ + "{}: {:.4g}".format(k, v.median(storage.count_samples(k, self._window_size))) + for k, v in storage.histories().items() + if "loss" in k + ] + ), + non_losses=" ".join( + [ + "{}: {:.4g}".format(k, v.median(storage.count_samples(k, self._window_size))) + for k, v in storage.histories().items() + if "[metric]" in k + ] + ), + avg_time=("time: {:.4f} ".format(avg_iter_time) if avg_iter_time is not None else ""), + last_time=("last_time: {:.4f} ".format(last_iter_time) if last_iter_time is not None else ""), + avg_data_time=("data_time: {:.4f} ".format(avg_data_time) if avg_data_time is not None else ""), + last_data_time=( + "last_data_time: {:.4f} ".format(last_data_time) if last_data_time is not None else "" + ), + lr=lr, + memory=("max_mem: {:.0f}M".format(max_mem_mb) if max_mem_mb is not None else ""), + ) + ) + + +class EventStorage: + """ + The user-facing class that provides metric storage functionalities. + + In the future we may add support for storing / logging other types of data if needed. + """ + + def __init__(self, start_iter=0): + """ + Args: + start_iter (int): the iteration number to start with + """ + self._history = defaultdict(HistoryBuffer) + self._smoothing_hints = {} + self._latest_scalars = {} + self._iter = start_iter + self._current_prefix = "" + self._vis_data = [] + self._histograms = [] + + def put_image(self, img_name, img_tensor): + """ + Add an `img_tensor` associated with `img_name`, to be shown on + tensorboard. + + Args: + img_name (str): The name of the image to put into tensorboard. + img_tensor (torch.Tensor or numpy.array): An `uint8` or `float` + Tensor of shape `[channel, height, width]` where `channel` is + 3. The image format should be RGB. The elements in img_tensor + can either have values in [0, 1] (float32) or [0, 255] (uint8). + The `img_tensor` will be visualized in tensorboard. + """ + self._vis_data.append((img_name, img_tensor, self._iter)) + + def put_scalar(self, name, value, smoothing_hint=True, cur_iter=None): + """ + Add a scalar `value` to the `HistoryBuffer` associated with `name`. + + Args: + smoothing_hint (bool): a 'hint' on whether this scalar is noisy and should be + smoothed when logged. The hint will be accessible through + :meth:`EventStorage.smoothing_hints`. A writer may ignore the hint + and apply custom smoothing rule. + + It defaults to True because most scalars we save need to be smoothed to + provide any useful signal. + cur_iter (int): an iteration number to set explicitly instead of current iteration + """ + name = self._current_prefix + name + cur_iter = self._iter if cur_iter is None else cur_iter + history = self._history[name] + value = float(value) if value is not None else -1 + history.update(value, cur_iter) + self._latest_scalars[name] = (value, cur_iter) + + existing_hint = self._smoothing_hints.get(name) + + if existing_hint is not None: + assert existing_hint == smoothing_hint, "Scalar {} was put with a different smoothing_hint!".format(name) + else: + self._smoothing_hints[name] = smoothing_hint + + def put_scalars(self, *, smoothing_hint=True, cur_iter=None, **kwargs): + """ + Put multiple scalars from keyword arguments. + + Examples: + + storage.put_scalars(loss=my_loss, accuracy=my_accuracy, smoothing_hint=True) + """ + for k, v in kwargs.items(): + self.put_scalar(k, v, smoothing_hint=smoothing_hint, cur_iter=cur_iter) + + def put_histogram(self, hist_name, hist_tensor, bins=1000): + """ + Create a histogram from a tensor. + + Args: + hist_name (str): The name of the histogram to put into tensorboard. + hist_tensor (torch.Tensor): A Tensor of arbitrary shape to be converted + into a histogram. + bins (int): Number of histogram bins. + """ + ht_min, ht_max = hist_tensor.min().item(), hist_tensor.max().item() + + # Create a histogram with PyTorch + hist_counts = torch.histc(hist_tensor, bins=bins) + hist_edges = torch.linspace(start=ht_min, end=ht_max, steps=bins + 1, dtype=torch.float32) + + # Parameter for the add_histogram_raw function of SummaryWriter + hist_params = dict( + tag=hist_name, + min=ht_min, + max=ht_max, + num=len(hist_tensor), + sum=float(hist_tensor.sum()), + sum_squares=float(torch.sum(hist_tensor**2)), + bucket_limits=hist_edges[1:].tolist(), + bucket_counts=hist_counts.tolist(), + global_step=self._iter, + ) + self._histograms.append(hist_params) + + def history(self, name): + """ + Returns: + HistoryBuffer: the scalar history for name + """ + ret = self._history.get(name, None) + if ret is None: + raise KeyError("No history metric available for {}!".format(name)) + return ret + + def histories(self): + """ + Returns: + dict[name -> HistoryBuffer]: the HistoryBuffer for all scalars + """ + return self._history + + def latest(self): + """ + Returns: + dict[str -> (float, int)]: mapping from the name of each scalar to the most + recent value and the iteration number its added. + """ + return self._latest_scalars + + def latest_with_smoothing_hint(self, window_size=20): + """ + Similar to :meth:`latest`, but the returned values + are either the un-smoothed original latest value, + or a median of the given window_size, + depend on whether the smoothing_hint is True. + + This provides a default behavior that other writers can use. + + Note: All scalars saved in the past `window_size` iterations are used for smoothing. + This is different from the `window_size` definition in HistoryBuffer. + Use :meth:`get_history_window_size` to get the `window_size` used in HistoryBuffer. + """ + result = {} + for k, (v, itr) in self._latest_scalars.items(): + result[k] = ( + (self._history[k].median(self.count_samples(k, window_size)) if self._smoothing_hints[k] else v), + itr, + ) + return result + + def count_samples(self, name, window_size=20): + """ + Return the number of samples logged in the past `window_size` iterations. + """ + samples = 0 + data = self._history[name].values() + for _, iter_ in reversed(data): + if iter_ > data[-1][1] - window_size: + samples += 1 + else: + break + return samples + + def smoothing_hints(self): + """ + Returns: + dict[name -> bool]: the user-provided hint on whether the scalar + is noisy and needs smoothing. + """ + return self._smoothing_hints + + def step(self): + """ + User should either: (1) Call this function to increment storage.iter when needed. Or + (2) Set `storage.iter` to the correct iteration number before each iteration. + + The storage will then be able to associate the new data with an iteration number. + """ + self._iter += 1 + + @property + def iter(self): + """ + Returns: + int: The current iteration number. When used together with a trainer, + this is ensured to be the same as trainer.iter. + """ + return self._iter + + @iter.setter + def iter(self, val): + self._iter = int(val) + + @property + def iteration(self): + # for backward compatibility + return self._iter + + def __enter__(self): + _CURRENT_STORAGE_STACK.append(self) + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + assert _CURRENT_STORAGE_STACK[-1] == self + _CURRENT_STORAGE_STACK.pop() + + @contextmanager + def name_scope(self, name): + """ + Yields: + A context within which all the events added to this storage + will be prefixed by the name scope. + """ + old_prefix = self._current_prefix + self._current_prefix = name.rstrip("/") + "/" + yield + self._current_prefix = old_prefix + + def clear_images(self): + """ + Delete all the stored images for visualization. This should be called + after images are written to tensorboard. + """ + self._vis_data = [] + + def clear_histograms(self): + """ + Delete all the stored histograms for visualization. + This should be called after histograms are written to tensorboard. + """ + self._histograms = [] diff --git a/focoos/trainer/hooks/__init__.py b/focoos/trainer/hooks/__init__.py new file mode 100644 index 00000000..b5a4b0a5 --- /dev/null +++ b/focoos/trainer/hooks/__init__.py @@ -0,0 +1,32 @@ +from .base import HookBase +from .early_stop import EarlyStopException, EarlyStoppingHook +from .hook import ( + AutogradProfiler, + BestCheckpointer, + CallbackHook, + EvalHook, + IterationTimer, + LRScheduler, + PeriodicCheckpointer, + PeriodicWriter, + TorchMemoryStats, + TorchProfiler, +) +from .visualization import VisualizationHook + +__all__ = [ + "HookBase", + "EarlyStopException", + "EarlyStoppingHook", + "AutogradProfiler", + "BestCheckpointer", + "CallbackHook", + "EvalHook", + "IterationTimer", + "LRScheduler", + "PeriodicCheckpointer", + "PeriodicWriter", + "TorchMemoryStats", + "TorchProfiler", + "VisualizationHook", +] diff --git a/focoos/trainer/hooks/base.py b/focoos/trainer/hooks/base.py new file mode 100644 index 00000000..286b3d8f --- /dev/null +++ b/focoos/trainer/hooks/base.py @@ -0,0 +1,77 @@ +# Copyright (c) FocoosAI +# Part of the code has been copied and adapted from Detectron2 (c) Facebook, Inc. and its affiliates. + + +class HookBase: + """ + Base class for hooks that can be registered with :class:`TrainerBase`. + + Each hook can implement 4 methods. The way they are called is demonstrated + in the following snippet: + :: + hook.before_train() + for iter in range(start_iter, max_iter): + hook.before_step() + trainer.run_step() + hook.after_step() + iter += 1 + hook.after_train() + + Notes: + 1. In the hook method, users can access ``self.trainer`` to access more + properties about the context (e.g., model, current iteration, or config + if using :class:`DefaultTrainer`). + + 2. A hook that does something in :meth:`before_step` can often be + implemented equivalently in :meth:`after_step`. + If the hook takes non-trivial time, it is strongly recommended to + implement the hook in :meth:`after_step` instead of :meth:`before_step`. + The convention is that :meth:`before_step` should only take negligible time. + + Following this convention will allow hooks that do care about the difference + between :meth:`before_step` and :meth:`after_step` (e.g., timer) to + function properly. + + """ + + trainer = None + """ + A weak reference to the trainer object. Set by the trainer when the hook is registered. + """ + + def before_train(self): + """ + Called before the first iteration. + """ + pass + + def after_train(self): + """ + Called after the last iteration. + """ + pass + + def before_step(self): + """ + Called before each iteration. + """ + pass + + def after_backward(self): + """ + Called after the backward pass of each iteration. + """ + pass + + def after_step(self): + """ + Called after each iteration. + """ + pass + + def state_dict(self): + """ + Hooks are stateless by default, but can be made checkpointable by + implementing `state_dict` and `load_state_dict`. + """ + return {} diff --git a/focoos/trainer/hooks/early_stop.py b/focoos/trainer/hooks/early_stop.py new file mode 100644 index 00000000..26182395 --- /dev/null +++ b/focoos/trainer/hooks/early_stop.py @@ -0,0 +1,75 @@ +from focoos.trainer.hooks.base import HookBase +from focoos.utils.logger import get_logger + + +class EarlyStopException(Exception): + def __init__(self, *args: object) -> None: + super().__init__(*args) + + +class EarlyStoppingHook(HookBase): + def __init__( + self, + enabled: bool, + eval_period: int, + patience: int, + val_metric: str, + mode: str = "max", + ): + """ + Initializes the EarlyStoppingHook. + + This hook is designed to monitor a specific validation metric during the training process + and stop training when no improvement is observed in the metric for a specified number of + iterations. This is particularly useful for preventing overfitting by halting training + once the model's performance on the validation set no longer improves. + + Args: + eval_period (int): The frequency (in iterations) at which the validation metric is evaluated. + For example, if `eval_period` is 100, the validation metric will be checked + every 100 iterations. + patience (int): Number of consecutive evaluations with no improvement after which training will be stopped. + For example, if `patience` is set to 5, training will stop if the validation metric does not + improve for 5 consecutive evaluations. + val_metric (str): The name of the validation metric to monitor. This should correspond to one of the metrics + calculated during the validation phase, such as "accuracy", "loss", etc. + mode (str, optional): One of "min" or "max". This parameter dictates the direction of improvement + for the validation metric. In "min" mode, the training will stop when the monitored + quantity (e.g., loss) stops decreasing. In "max" mode, training will stop when + the monitored quantity (e.g., accuracy) stops increasing. Defaults to "max". + + """ + self.enabled = enabled + self.patience = patience + self.val_metric = val_metric + self.mode = mode + self.best_metric = None + self.num_bad_epochs = 0 + self._period = eval_period + self._logger = get_logger(__name__) + + def after_step(self): + next_iter = self.trainer.iter + 1 + + if self._period > 0 and next_iter % self._period == 0 and next_iter != self.trainer.max_iter and self.enabled: + metric_tuple = self.trainer.storage.latest().get(self.val_metric) + + if metric_tuple is None: + return + else: + current_metric, metric_iter = metric_tuple + + if ( + self.best_metric is None + or (self.mode == "max" and current_metric > self.best_metric) + or (self.mode == "min" and current_metric < self.best_metric) + ): + self.best_metric = current_metric + self.num_bad_epochs = 0 + else: + self.num_bad_epochs += 1 + self._logger.info(f"{self.num_bad_epochs}/{self.patience} without improvements..") + + if self.num_bad_epochs >= self.patience: + self.trainer.storage.put_scalar("early_stopping", True) + raise EarlyStopException("Early Stopping Exception to stop the training..") diff --git a/focoos/trainer/hooks/hook.py b/focoos/trainer/hooks/hook.py new file mode 100644 index 00000000..184a1567 --- /dev/null +++ b/focoos/trainer/hooks/hook.py @@ -0,0 +1,610 @@ +# Copyright (c) FocoosAI +# Part of the code has been copied and adapted from Detectron2 (c) Facebook, Inc. and its affiliates. +import datetime +import math +import operator +import os +import tempfile +import time +import warnings +from collections import Counter +from typing import Mapping + +import torch + +try: + from torch.optim.lr_scheduler import LRScheduler as _LRScheduler +except ImportError: + from torch.optim.lr_scheduler import _LRScheduler + +from focoos.trainer.checkpointer import Checkpointer +from focoos.trainer.checkpointer import PeriodicCheckpointer as _PeriodicCheckpointer +from focoos.trainer.events import EventWriter +from focoos.utils.distributed import comm +from focoos.utils.logger import get_logger +from focoos.utils.timer import Timer + +from .base import HookBase + +__all__ = [ + "CallbackHook", + "IterationTimer", + "PeriodicWriter", + "PeriodicCheckpointer", + "BestCheckpointer", + "LRScheduler", + "AutogradProfiler", + "EvalHook", + "TorchProfiler", + "TorchMemoryStats", +] + + +""" +Implement some common hooks. +""" +logger = get_logger(__name__) + + +class CallbackHook(HookBase): + """ + Create a hook using callback functions provided by the user. + """ + + def __init__(self, *, before_train=None, after_train=None, before_step=None, after_step=None): + """ + Each argument is a function that takes one argument: the trainer. + """ + self._before_train = before_train + self._before_step = before_step + self._after_step = after_step + self._after_train = after_train + + def before_train(self): + if self._before_train: + self._before_train(self.trainer) + + def after_train(self): + if self._after_train: + self._after_train(self.trainer) + # The functions may be closures that hold reference to the trainer + # Therefore, delete them to avoid circular reference. + del self._before_train, self._after_train + del self._before_step, self._after_step + + def before_step(self): + if self._before_step: + self._before_step(self.trainer) + + def after_step(self): + if self._after_step: + self._after_step(self.trainer) + + +class IterationTimer(HookBase): + """ + Track the time spent for each iteration (each run_step call in the trainer). + Print a summary in the end of training. + + This hook uses the time between the call to its :meth:`before_step` + and :meth:`after_step` methods. + Under the convention that :meth:`before_step` of all hooks should only + take negligible amount of time, the :class:`IterationTimer` hook should be + placed at the beginning of the list of hooks to obtain accurate timing. + """ + + def __init__(self, warmup_iter=3): + """ + Args: + warmup_iter (int): the number of iterations at the beginning to exclude + from timing. + """ + self._warmup_iter = warmup_iter + self._step_timer = Timer() + self._start_time = time.perf_counter() + self._total_timer = Timer() + self.trainer = None + + def before_train(self): + self._start_time = time.perf_counter() + self._total_timer.reset() + self._total_timer.pause() + + def after_train(self): + total_time = time.perf_counter() - self._start_time + total_time_minus_hooks = self._total_timer.seconds() + hook_time = total_time - total_time_minus_hooks + + num_iter = self.trainer.storage.iter + 1 - self.trainer.start_iter - self._warmup_iter + + if num_iter > 0 and total_time_minus_hooks > 0: + # Speed is meaningful only after warmup + # NOTE this format is parsed by grep in some scripts + logger.info( + "Overall training speed: {} iterations in {} ({:.4f} s / it)".format( + num_iter, + str(datetime.timedelta(seconds=int(total_time_minus_hooks))), + total_time_minus_hooks / num_iter, + ) + ) + + logger.info( + "Total training time: {} ({} on hooks)".format( + str(datetime.timedelta(seconds=int(total_time))), + str(datetime.timedelta(seconds=int(hook_time))), + ) + ) + + def before_step(self): + self._step_timer.reset() + self._total_timer.resume() + + def after_step(self): + # +1 because we're in after_step, the current step is done + # but not yet counted + iter_done = self.trainer.storage.iter - self.trainer.start_iter + 1 + if iter_done >= self._warmup_iter: + sec = self._step_timer.seconds() + self.trainer.storage.put_scalars(time=sec) + else: + self._start_time = time.perf_counter() + self._total_timer.reset() + + self._total_timer.pause() + + +class PeriodicWriter(HookBase): + """ + Write events to EventStorage (by calling ``writer.write()``) periodically. + + It is executed every ``period`` iterations and after the last iteration. + Note that ``period`` does not affect how data is smoothed by each writer. + """ + + def __init__(self, writers, period=20): + """ + Args: + writers (list[EventWriter]): a list of EventWriter objects + period (int): + """ + self._writers = writers + for w in writers: + assert isinstance(w, EventWriter), w + self._period = period + + def after_step(self): + if (self.trainer.iter + 1) % self._period == 0 or (self.trainer.iter == self.trainer.max_iter - 1): + for writer in self._writers: + writer.write() + + def after_train(self): + for writer in self._writers: + # If any new data is found (e.g. produced by other after_train), + # write them before closing + writer.write() + writer.close() + + +class PeriodicCheckpointer(_PeriodicCheckpointer, HookBase): + """ + Same as :class:`detectron2.checkpoint.PeriodicCheckpointer`, but as a hook. + + Note that when used as a hook, + it is unable to save additional data other than what's defined + by the given `checkpointer`. + + It is executed every ``period`` iterations and after the last iteration. + """ + + def before_train(self): + self.max_iter = self.trainer.max_iter + + def after_step(self): + # No way to use **kwargs + self.step(self.trainer.iter) + + +class BestCheckpointer(HookBase): + """ + Checkpoints best weights based off given metric. + + This hook should be used in conjunction to and executed after the hook + that produces the metric, e.g. `EvalHook`. + """ + + def __init__( + self, + eval_period: int, + checkpointer: Checkpointer, + val_metric: str, + mode: str = "max", + file_prefix: str = "model_best", + ) -> None: + """ + Args: + eval_period (int): the period `EvalHook` is set to run. + checkpointer: the checkpointer object used to save checkpoints. + val_metric (str): validation metric to track for best checkpoint, e.g. "bbox/AP50" + mode (str): one of {'max', 'min'}. controls whether the chosen val metric should be + maximized or minimized, e.g. for "bbox/AP50" it should be "max" + file_prefix (str): the prefix of checkpoint's filename, defaults to "model_best" + """ + self._period = eval_period + self._val_metric = val_metric + assert mode in [ + "max", + "min", + ], f'Mode "{mode}" to `BestCheckpointer` is unknown. It should be one of {"max", "min"}.' + if mode == "max": + self._compare = operator.gt + else: + self._compare = operator.lt + self._checkpointer = checkpointer + self._file_prefix = file_prefix + self.best_metric = None + self.best_iter = None + + def _update_best(self, val, iteration): + if math.isnan(val) or math.isinf(val): + return False + self.best_metric = val + self.best_iter = iteration + return True + + def _best_checking(self): + metric_tuple = self.trainer.storage.latest().get(self._val_metric) + if metric_tuple is None: + logger.warning( + f"Given val metric {self._val_metric} does not seem to be computed/stored." + "Will not be checkpointing based on it." + ) + return + else: + latest_metric, metric_iter = metric_tuple + + if self.best_metric is None: + if self._update_best(latest_metric, metric_iter): + additional_state = {"iteration": metric_iter} + self._checkpointer.save(f"{self._file_prefix}", **additional_state) + logger.info(f"Saved first model at {self.best_metric:0.5f} @ {self.best_iter} steps") + elif self._compare(latest_metric, self.best_metric): + additional_state = {"iteration": metric_iter} + self._checkpointer.save(f"{self._file_prefix}", **additional_state) + logger.info( + f"Saved best model as latest eval score for {self._val_metric} is " + f"{latest_metric:0.5f}, better than last best score " + f"{self.best_metric:0.5f} @ iteration {self.best_iter}." + ) + self._update_best(latest_metric, metric_iter) + else: + logger.info( + f"Not saving as latest eval score for {self._val_metric} is {latest_metric:0.5f}, " + f"not better than best score {self.best_metric:0.5f} @ iteration {self.best_iter}." + ) + + def after_step(self): + # same conditions as `EvalHook` + next_iter = self.trainer.iter + 1 + if self._period > 0 and next_iter % self._period == 0 and next_iter != self.trainer.max_iter: + self._best_checking() + + def after_train(self): + # same conditions as `EvalHook` + if self.trainer.iter + 1 >= self.trainer.max_iter: + self._best_checking() + + +class LRScheduler(HookBase): + """ + A hook which executes a torch builtin LR scheduler and summarizes the LR. + It is executed after every iteration. + """ + + def __init__(self, optimizer=None, scheduler=None): + """ + Args: + optimizer (torch.optim.Optimizer): + scheduler (torch.optim.LRScheduler): + if a :class:`ParamScheduler` object, it defines the multiplier over the base LR + in the optimizer. + + If any argument is not given, will try to obtain it from the trainer. + """ + self._optimizer = optimizer + self._scheduler = scheduler + + def before_train(self): + self._optimizer = self._optimizer or self.trainer.optimizer + self._best_param_group_id = LRScheduler.get_best_param_group_id(self._optimizer) + + @staticmethod + def get_best_param_group_id(optimizer): + # NOTE: some heuristics on what LR to summarize + # summarize the param group with most parameters + largest_group = max(len(g["params"]) for g in optimizer.param_groups) + + if largest_group == 1: + # If all groups have one parameter, + # then find the most common initial LR, and use it for summary + lr_count = Counter([g["lr"] for g in optimizer.param_groups]) + lr = lr_count.most_common()[0][0] + for i, g in enumerate(optimizer.param_groups): + if g["lr"] == lr: + return i + else: + for i, g in enumerate(optimizer.param_groups): + if len(g["params"]) == largest_group: + return i + + def after_step(self): + lr = self._optimizer.param_groups[self._best_param_group_id]["lr"] + self.trainer.storage.put_scalar("lr", lr, smoothing_hint=False) + self.scheduler.step() + + @property + def scheduler(self): + return self._scheduler or self.trainer.scheduler + + def state_dict(self): + if isinstance(self.scheduler, _LRScheduler): + return self.scheduler.state_dict() + return {} + + def load_state_dict(self, state_dict): + if isinstance(self.scheduler, _LRScheduler): + logger.info("Loading scheduler from state_dict ...") + self.scheduler.load_state_dict(state_dict) + + +class TorchProfiler(HookBase): + """ + A hook which runs `torch.profiler.profile`. + + Examples: + :: + hooks.TorchProfiler(lambda trainer: 10 < trainer.iter < 20, self.cfg.OUTPUT_DIR) + + The above example will run the profiler for iteration 10~20 and dump + results to ``OUTPUT_DIR``. We did not profile the first few iterations + because they are typically slower than the rest. + The result files can be loaded in the ``chrome://tracing`` page in chrome browser, + and the tensorboard visualizations can be visualized using + ``tensorboard --logdir OUTPUT_DIR/log`` + """ + + def __init__(self, enable_predicate, output_dir, *, activities=None, save_tensorboard=True): + """ + Args: + enable_predicate (callable[trainer -> bool]): a function which takes a trainer, + and returns whether to enable the profiler. + It will be called once every step, and can be used to select which steps to profile. + output_dir (str): the output directory to dump tracing files. + activities (iterable): same as in `torch.profiler.profile`. + save_tensorboard (bool): whether to save tensorboard visualizations at (output_dir)/log/ + """ + self._enable_predicate = enable_predicate + self._activities = activities + self._output_dir = output_dir + self._save_tensorboard = save_tensorboard + + def before_step(self): + if self._enable_predicate(self.trainer): + if self._save_tensorboard: + on_trace_ready = torch.profiler.tensorboard_trace_handler( + os.path.join( + self._output_dir, + "log", + "profiler-tensorboard-iter{}".format(self.trainer.iter), + ), + f"worker{comm.get_rank()}", + ) + else: + on_trace_ready = None + self._profiler = torch.profiler.profile( + activities=self._activities, + on_trace_ready=on_trace_ready, + record_shapes=True, + profile_memory=True, + with_stack=True, + with_flops=True, + ) + self._profiler.__enter__() + else: + self._profiler = None + + def after_step(self): + if self._profiler is None: + return + self._profiler.__exit__(None, None, None) + if not self._save_tensorboard: + os.makedirs(self._output_dir, exist_ok=True) + out_file = os.path.join(self._output_dir, "profiler-trace-iter{}.json".format(self.trainer.iter)) + if "://" not in out_file: + self._profiler.export_chrome_trace(out_file) + else: + # Support non-posix filesystems + with tempfile.TemporaryDirectory(prefix="detectron2_profiler") as d: + tmp_file = os.path.join(d, "tmp.json") + self._profiler.export_chrome_trace(tmp_file) + with open(tmp_file) as f: + content = f.read() + with open(out_file, "w") as f: + f.write(content) + + +class AutogradProfiler(TorchProfiler): + """ + A hook which runs `torch.autograd.profiler.profile`. + + Examples: + :: + hooks.AutogradProfiler(lambda trainer: 10 < trainer.iter < 20, self.cfg.OUTPUT_DIR) + + The above example will run the profiler for iteration 10~20 and dump + results to ``OUTPUT_DIR``. We did not profile the first few iterations + because they are typically slower than the rest. + The result files can be loaded in the ``chrome://tracing`` page in chrome browser. + + Note: + When used together with NCCL on older version of GPUs, + autograd profiler may cause deadlock because it unnecessarily allocates + memory on every device it sees. The memory management calls, if + interleaved with NCCL calls, lead to deadlock on GPUs that do not + support ``cudaLaunchCooperativeKernelMultiDevice``. + """ + + def __init__(self, enable_predicate, output_dir, *, use_cuda=True): + """ + Args: + enable_predicate (callable[trainer -> bool]): a function which takes a trainer, + and returns whether to enable the profiler. + It will be called once every step, and can be used to select which steps to profile. + output_dir (str): the output directory to dump tracing files. + use_cuda (bool): same as in `torch.autograd.profiler.profile`. + """ + warnings.warn("AutogradProfiler has been deprecated in favor of TorchProfiler.") + self._enable_predicate = enable_predicate + self._use_cuda = use_cuda + self._output_dir = output_dir + + def before_step(self): + if self._enable_predicate(self.trainer): + self._profiler = torch.autograd.profiler.profile(use_cuda=self._use_cuda) + self._profiler.__enter__() + else: + self._profiler = None + + +def flatten_results_dict(results): + """ + Expand a hierarchical dict of scalars into a flat dict of scalars. + If results[k1][k2][k3] = v, the returned dict will have the entry + {"k1/k2/k3": v}. + + Args: + results (dict): + """ + r = {} + for k, v in results.items(): + if isinstance(v, Mapping): + v = flatten_results_dict(v) + for kk, vv in v.items(): + r[k + "/" + kk] = vv + else: + r[k] = v + return r + + +class EvalHook(HookBase): + """ + Run an evaluation function periodically, and at the end of training. + + It is executed every ``eval_period`` iterations and after the last iteration. + """ + + def __init__(self, eval_period, eval_function, eval_after_train=True): + """ + Args: + eval_period (int): the period to run `eval_function`. Set to 0 to + not evaluate periodically (but still evaluate after the last iteration + if `eval_after_train` is True). + eval_function (callable): a function which takes no arguments, and + returns a nested dict of evaluation metrics. + eval_after_train (bool): whether to evaluate after the last iteration + + Note: + This hook must be enabled in all or none workers. + If you would like only certain workers to perform evaluation, + give other workers a no-op function (`eval_function=lambda: None`). + """ + self._period = eval_period + self._func = eval_function + self._eval_after_train = eval_after_train + + def _do_eval(self): + results = self._func() + + if results: + assert isinstance(results, dict), "Eval function must return a dict. Got {} instead.".format(results) + + flattened_results = flatten_results_dict(results) + for k, v in flattened_results.items(): + try: + v = float(v) if v is not None else -1 + except Exception as e: + raise ValueError( + "[EvalHook] eval_function should return a nested dict of float. Got '{}: {}' instead.".format( + k, v + ) + ) from e + self.trainer.storage.put_scalars(**flattened_results, smoothing_hint=False) + + # Evaluation may take different time among workers. + # A barrier make them start the next iteration together. + comm.synchronize() + + def after_step(self): + next_iter = self.trainer.iter + 1 + if self._period > 0 and next_iter % self._period == 0: + # do the last eval in after_train + if next_iter != self.trainer.max_iter: + self._do_eval() + + def after_train(self): + # This condition is to prevent the eval from running after a failed training + if self._eval_after_train and self.trainer.iter + 1 >= self.trainer.max_iter: + self._do_eval() + # func is likely a closure that holds reference to the trainer + # therefore we clean it to avoid circular reference in the end + del self._func + + +class TorchMemoryStats(HookBase): + """ + Writes pytorch's cuda memory statistics periodically. + """ + + def __init__(self, period=20, max_runs=10): + """ + Args: + period (int): Output stats each 'period' iterations + max_runs (int): Stop the logging after 'max_runs' + """ + + self._period = period + self._max_runs = max_runs + self._runs = 0 + + def after_step(self): + if self._runs > self._max_runs: + return + + if (self.trainer.iter + 1) % self._period == 0 or (self.trainer.iter == self.trainer.max_iter - 1): + if torch.cuda.is_available(): + max_reserved_mb = torch.cuda.max_memory_reserved() / 1024.0 / 1024.0 + reserved_mb = torch.cuda.memory_reserved() / 1024.0 / 1024.0 + max_allocated_mb = torch.cuda.max_memory_allocated() / 1024.0 / 1024.0 + allocated_mb = torch.cuda.memory_allocated() / 1024.0 / 1024.0 + + logger.info( + ( + " iter: {} " + " max_reserved_mem: {:.0f}MB " + " reserved_mem: {:.0f}MB " + " max_allocated_mem: {:.0f}MB " + " allocated_mem: {:.0f}MB " + ).format( + self.trainer.iter, + max_reserved_mb, + reserved_mb, + max_allocated_mb, + allocated_mb, + ) + ) + + self._runs += 1 + if self._runs == self._max_runs: + mem_summary = torch.cuda.memory_summary() + logger.info("\n" + mem_summary) + + torch.cuda.reset_peak_memory_stats() diff --git a/focoos/trainer/hooks/sync_to_hub.py b/focoos/trainer/hooks/sync_to_hub.py new file mode 100644 index 00000000..8c805727 --- /dev/null +++ b/focoos/trainer/hooks/sync_to_hub.py @@ -0,0 +1,114 @@ +import os +import sys +from datetime import datetime +from typing import List, Optional + +from focoos.hub.remote_model import RemoteModel +from focoos.ports import ArtifactName, HubSyncLocalTraining, ModelInfo, ModelStatus +from focoos.trainer.hooks.base import HookBase +from focoos.utils.logger import get_logger + +logger = get_logger("SyncToHubHook") + + +class SyncToHubHook(HookBase): + def __init__( + self, + remote_model: RemoteModel, + model_info: ModelInfo, + output_dir: str, + sync_period: int = 100, + eval_period: int = 100, + ): + self.remote_model = remote_model + self.model_info = model_info + self.output_dir = output_dir + self.sync_period = sync_period + self.eval_period = eval_period + + @property + def iteration(self): + try: + _iter = self.trainer.iter + except Exception: + _iter = 1 + return _iter + + def before_train(self): + """ + Called before the first iteration. + """ + info = HubSyncLocalTraining( + status=ModelStatus.TRAINING_RUNNING, # type: ignore + iterations=0, + metrics=None, + ) + + self._sync_train_job(info) + + def after_step(self): + if (self.iteration % self.sync_period == 0) and self.iteration > 0: + self._sync_train_job( + sync_info=HubSyncLocalTraining( + status=ModelStatus.TRAINING_RUNNING, # type: ignore + iterations=self.iteration, + training_info=self.model_info.training_info, + ) + ) + + elif (self.iteration % (self.eval_period + 3) == 0) and self.iteration > 0: + self._sync_train_job( + sync_info=HubSyncLocalTraining( + status=ModelStatus.TRAINING_RUNNING, # type: ignore + iterations=self.iteration, + training_info=self.model_info.training_info, + ), + upload_artifacts=[ArtifactName.WEIGHTS], + ) + + def after_train(self): + exc_type, exc_value, exc_traceback = sys.exc_info() + status = ModelStatus.TRAINING_COMPLETED + if exc_type is not None: + logger.error( + f"Exception during training, status set to TRAINING_ERROR: {str(exc_type.__name__)} {str(exc_value)}" + ) + status = ModelStatus.TRAINING_ERROR + self.model_info.status = status + if self.model_info.training_info is not None: + self.model_info.training_info.main_status = status + self.model_info.training_info.failure_reason = str(exc_value) + self.model_info.training_info.end_time = datetime.now().isoformat() + if self.model_info.training_info.status_transitions is None: + self.model_info.training_info.status_transitions = [] + self.model_info.training_info.status_transitions.append( + dict( + status=status, + timestamp=datetime.now().isoformat(), + detail=f"{str(exc_type.__name__)}: {str(exc_value)}", + ) + ) + + self.model_info.dump_json(os.path.join(self.output_dir, ArtifactName.INFO)) + self._sync_train_job( + sync_info=HubSyncLocalTraining( + status=status, + iterations=self.iteration, + training_info=self.model_info.training_info, + ), + upload_artifacts=[ + ArtifactName.WEIGHTS, + ArtifactName.LOGS, + ArtifactName.PT, + ArtifactName.ONNX, + ArtifactName.INFO, + ArtifactName.METRICS, + ], + ) + + def _sync_train_job(self, sync_info: HubSyncLocalTraining, upload_artifacts: Optional[List[ArtifactName]] = None): + try: + self.remote_model.sync_local_training_job(sync_info, self.output_dir, upload_artifacts) + # logger.debug(f"Sync: {self.iteration} {self.model_info.name} ref: {self.model_info.ref}") + except Exception as e: + logger.error(f"[sync_train_job] failed to sync train job: {str(e)}") diff --git a/focoos/trainer/hooks/visualization.py b/focoos/trainer/hooks/visualization.py new file mode 100644 index 00000000..3b6ac188 --- /dev/null +++ b/focoos/trainer/hooks/visualization.py @@ -0,0 +1,222 @@ +# Copyright (c) FocoosAI +import math +import os +import random +from contextlib import ExitStack, contextmanager +from typing import Optional + +import cv2 +import numpy as np +import torch + +from focoos.data.datasets.map_dataset import MapDataset +from focoos.models.focoos_model import BaseModelNN +from focoos.processor.base_processor import Processor +from focoos.trainer.events import get_event_storage +from focoos.utils.logger import get_logger +from focoos.utils.visualizer import ColorMode, Visualizer + +from .base import HookBase + +logger = get_logger("VisualizationHook") + + +@contextmanager +def inference_context(model): + """ + A context where the model is temporarily changed to eval mode, + and restored to previous mode afterwards. + + Args: + model: a torch Module + """ + training_mode = model.training + model.eval() + yield + model.train(training_mode) + + +class VisualizationHook(HookBase): + def __init__( + self, + model: BaseModelNN, + processor: Processor, + dataset: MapDataset, + period, + index_list=[], + n_sample=4, + random_samples=False, + output_dir: Optional[str] = None, + ): + self.model = model + self.processor = processor + # self.postprocessing = postprocessing + self._period = period + if index_list is None or len(index_list) <= 0: + if random_samples: + index_list = random.sample(range(len(dataset)), k=min(len(dataset), n_sample)) + else: + index_list = [i for i in range(min(len(dataset), n_sample))] + self.n_sample = len(index_list) + + self.samples = [dataset[i] for i in index_list] + self.metadata = dataset.dataset.metadata + self.cpu_device = torch.device("cpu") + self.output_dir = output_dir + + def _create_mosaic(self, images): + """ + Create a mosaic of images with max resolution 3000x3000. + + Args: + images: list of numpy arrays with shape (H, W, 3) + + Returns: + mosaic: numpy array with shape (H, W, 3) + """ + if not images: + logger.warning("No images to create mosaic") + return None + + # Calculate optimal grid dimensions + n_images = len(images) + grid_size = math.ceil(math.sqrt(n_images)) + grid_rows = math.ceil(n_images / grid_size) + grid_cols = grid_size + + # Check image sizes + heights = [img.shape[0] for img in images] + widths = [img.shape[1] for img in images] + + # Calculate target size for each image to fit in 3000x3000 + max_size = 3000 + target_height = min(int(max_size / grid_rows), max(heights)) + target_width = min(int(max_size / grid_cols), max(widths)) + + # Resize images to target size + resized_images = [] + for img in images: + # Preserve aspect ratio + h, w = img.shape[:2] + ratio = min(target_height / h, target_width / w) + new_h, new_w = int(h * ratio), int(w * ratio) + resized = cv2.resize(img, (new_w, new_h)) + + # Create padding to make all images the same size + padded = np.zeros((target_height, target_width, 3), dtype=np.uint8) + padded[:new_h, :new_w, :] = resized + resized_images.append(padded) + + # Create empty mosaic + mosaic_height = grid_rows * target_height + mosaic_width = grid_cols * target_width + mosaic = np.zeros((mosaic_height, mosaic_width, 3), dtype=np.uint8) + + # Place images in mosaic + for i, img in enumerate(resized_images): + row = i // grid_cols + col = i % grid_cols + y_start = row * target_height + x_start = col * target_width + mosaic[y_start : y_start + target_height, x_start : x_start + target_width] = img + + return mosaic + + def _visualize(self): + training_mode = self.model.training + + with ExitStack() as stack: + stack.enter_context(torch.no_grad()) + stack.enter_context(inference_context(self.model)) + stack.enter_context(inference_context(self.processor)) + + storage = get_event_storage() + self.model.eval() + + all_visualized_images = [] + + for i in range(self.n_sample): + sample = self.samples[i] + sample["height"], sample["width"] = sample["image"].shape[-2:] + + samples = [sample] + images, _ = self.processor.preprocess(samples, device=self.model.device, dtype=self.model.dtype) + outputs = self.model(images) + prediction = self.processor.eval_postprocess(outputs, samples)[0] + + visualizer = Visualizer( + sample["image"].permute(1, 2, 0).cpu().numpy(), + self.metadata, + instance_mode=ColorMode.IMAGE, + ) + if "panoptic_seg" in prediction: + panoptic_seg, segments_info = prediction["panoptic_seg"] + vis_output = visualizer.draw_panoptic_seg_predictions( + panoptic_seg.to(self.cpu_device), segments_info + ) + elif "sem_seg" in prediction: + vis_output = visualizer.draw_sem_seg(prediction["sem_seg"].argmax(dim=0).to(self.cpu_device)) + elif "instances" in prediction: + instances = prediction["instances"].to(self.cpu_device) + # filter based on confidence - fixed at 0.5 + instances = instances[instances.scores > 0.5] + vis_output = visualizer.draw_instance_predictions(predictions=instances) + else: + vis_output = None + + if vis_output is not None: + pred_img = vis_output.get_image() + # Non salviamo piรน i singoli samples nello storage + all_visualized_images.append(pred_img) + + # Create and save mosaic if we have images and output directory + if all_visualized_images: + # Get current iteration for filename + try: + current_iter = self.trainer.iter + except (AttributeError, TypeError): + current_iter = 0 + + # Create mosaic + mosaic = self._create_mosaic(all_visualized_images) + + if mosaic is not None: + # Salva il mosaico nello storage invece dei singoli samples + mosaic_transposed = mosaic.transpose(2, 0, 1) # HWC -> CHW + storage.put_image("Samples_Mosaic", mosaic_transposed) + + # Save to disk if output_dir is provided + if self.output_dir is not None: + preview_dir = os.path.join(self.output_dir, "preview") + os.makedirs(preview_dir, exist_ok=True) + + # Include iteration in filename + output_path = os.path.join(preview_dir, f"samples_iter_{current_iter}.jpg") + encode_params = [cv2.IMWRITE_JPEG_QUALITY, 80] + cv2.imwrite(output_path, mosaic, encode_params) + + # set model back to training mode + self.model.train(training_mode) + + @property + def iter(self): + try: + return self.trainer.iter + except Exception: + return 0 + + def after_step(self): + next_iter = self.iter + 1 + if self._period > 0 and next_iter % self._period == 0: + # do the last eval in after_train + if next_iter != self.trainer.max_iter: + self._visualize() + + def after_train(self): + try: + # This condition is to prevent the eval from running after a failed training + if self.trainer.max_iter is not None and self.iter + 1 >= self.trainer.max_iter: + self._visualize() + except (AttributeError, TypeError): + # In case self.trainer is None + self._visualize() diff --git a/focoos/trainer/solver/__init__.py b/focoos/trainer/solver/__init__.py new file mode 100755 index 00000000..cea94f9c --- /dev/null +++ b/focoos/trainer/solver/__init__.py @@ -0,0 +1,6 @@ +from .build import full_model_gradient_clipping, get_optimizer_params + +__all__ = [ + "full_model_gradient_clipping", + "get_optimizer_params", +] diff --git a/focoos/trainer/solver/build.py b/focoos/trainer/solver/build.py new file mode 100755 index 00000000..4a4c42b1 --- /dev/null +++ b/focoos/trainer/solver/build.py @@ -0,0 +1,159 @@ +import copy +import itertools +from typing import Any, Dict, List, Optional + +import torch + +from focoos.nn.layers.norm import LayerNorm as ConvNextLayerNorm + +from .lr_scheduler import ( + BaseLRScheduler, + WarmupCosineLR, + WarmupMultiStepLR, + WarmupPolyLR, +) + +_OPTIMIZERS = { + "ADAMW": torch.optim.AdamW, + "SGD": torch.optim.SGD, + "RMSPROP": torch.optim.RMSprop, +} +_SCHEDULERS = { + "FIXED": BaseLRScheduler, + "POLY": WarmupPolyLR, + "COSINE": WarmupCosineLR, + "MULTISTEP": WarmupMultiStepLR, +} + + +def full_model_gradient_clipping(optim, clip_norm_val): + class FullModelGradientClippingOptimizer(optim): + def step(self, closure=None): + all_params = itertools.chain(*[x["params"] for x in self.param_groups]) + torch.nn.utils.clip_grad_norm_(all_params, clip_norm_val) + super().step(closure=closure) + + return FullModelGradientClippingOptimizer + + +def get_optimizer_params( + model: torch.nn.Module, + base_lr: Optional[float] = None, + weight_decay: Optional[float] = None, + weight_decay_norm: Optional[float] = None, + weight_decay_embed: Optional[float] = None, + backbone_multiplier: float = 1.0, + decoder_multiplier: float = 1.0, + head_multiplier: float = 1.0, +) -> List[Dict[str, Any]]: + defaults = {} + defaults["lr"] = base_lr + defaults["weight_decay"] = weight_decay + + norm_module_types = ( + torch.nn.BatchNorm1d, + torch.nn.BatchNorm2d, + torch.nn.BatchNorm3d, + torch.nn.SyncBatchNorm, + # NaiveSyncBatchNorm inherits from BatchNorm2d + torch.nn.GroupNorm, + torch.nn.InstanceNorm1d, + torch.nn.InstanceNorm2d, + torch.nn.InstanceNorm3d, + torch.nn.LayerNorm, + torch.nn.LocalResponseNorm, + ConvNextLayerNorm, + ) + params = [] + memo = set() + for module_name, module in model.named_modules(): + # Module name is the outer key (such as backbone.stage1.0) + for module_param_name, value in module.named_parameters(recurse=False): + # module_param_name is the inner, such as weight, bias, etc. + if not value.requires_grad: + continue + # Avoid duplicating parameters + if value in memo: + continue + memo.add(value) + + hyperparams = copy.copy(defaults) + if "backbone" in module_name: + hyperparams["lr"] = hyperparams["lr"] * backbone_multiplier + if backbone_multiplier == 0: + hyperparams["weight_decay"] = 0.0 + if "pixel_decoder" in module_name: + hyperparams["lr"] = hyperparams["lr"] * decoder_multiplier + if backbone_multiplier == 0: + hyperparams["weight_decay"] = 0.0 + if "head" in module_name and "classifier" not in module_name: + hyperparams["lr"] = hyperparams["lr"] * head_multiplier + if head_multiplier == 0: + hyperparams["weight_decay"] = 0.0 + if isinstance(module, norm_module_types): + hyperparams["weight_decay"] = weight_decay_norm + if isinstance(module, torch.nn.Embedding) or "pos_embed" in module_param_name: # SegFormer and Swin: + hyperparams["weight_decay"] = weight_decay_embed + if "relative_position_bias_table" in module_param_name: # Swin (or attention in general) + hyperparams["weight_decay"] = 0.0 + params.append({"params": [value], **hyperparams}) + + return params + + +def build_optimizer( + name: str, + learning_rate: float, + weight_decay: float, + model: torch.nn.Module, + weight_decay_norm: float = 0.0, + weight_decay_embed: float = 0.0, + backbone_multiplier: float = 0.1, + decoder_multiplier: float = 1.0, + head_multiplier: float = 1.0, + clip_gradients: float = 0.1, + extra: Optional[dict] = None, +) -> torch.optim.Optimizer: + params = get_optimizer_params( + model, + base_lr=learning_rate, + weight_decay=weight_decay, + weight_decay_norm=weight_decay_norm, + weight_decay_embed=weight_decay_embed, + backbone_multiplier=backbone_multiplier, + decoder_multiplier=decoder_multiplier, + head_multiplier=head_multiplier, + ) + + if name.upper() in _OPTIMIZERS: + optimizer_class = _OPTIMIZERS[name.upper()] + else: + raise NotImplementedError(f"Optimizer {name} is not supported. Use one of {_OPTIMIZERS.keys()}.") + + if clip_gradients > 0.0: + optimizer_class = full_model_gradient_clipping(optimizer_class, clip_gradients) + + if extra is None: + extra = {} + return optimizer_class(params=params, lr=learning_rate, weight_decay=weight_decay, **extra) + + +def build_lr_scheduler( + name: str, + max_iters: int, + optimizer: torch.optim.Optimizer, + last_epoch: int = -1, + extra: Optional[dict] = None, +) -> BaseLRScheduler: + """ + Build a LR scheduler from config. + """ + if name.upper() in _SCHEDULERS: + scheduler_class = _SCHEDULERS[name.upper()] + else: + raise NotImplementedError(f"Scheduler {name} is not supported. Use one of {_SCHEDULERS.keys()}.") + + if extra is None: + extra = {} + + return scheduler_class(max_iters=max_iters, last_epoch=last_epoch, optimizer=optimizer, **extra) diff --git a/focoos/trainer/solver/ema.py b/focoos/trainer/solver/ema.py new file mode 100644 index 00000000..3adb58fb --- /dev/null +++ b/focoos/trainer/solver/ema.py @@ -0,0 +1,230 @@ +import copy +import itertools +import math +from contextlib import contextmanager +from typing import List + +import torch + +from focoos.trainer.hooks import HookBase +from focoos.utils.logger import get_logger + +logger = get_logger(__name__) + + +class EMAState: + def __init__(self): + self.state = {} + + @classmethod + def from_model(cls, model: torch.nn.Module, device: str = ""): + ret = cls() + ret.save_from(model, device) + return ret + + def save_from(self, model: torch.nn.Module, device: str = ""): + """Save model state from `model` to this object""" + for name, val in self.get_model_state_iterator(model): + val = val.detach().clone() + self.state[name] = val.to(device) if device else val + + def apply_to(self, model: torch.nn.Module): + """Apply state to `model` from this object""" + with torch.no_grad(): + for name, val in self.get_model_state_iterator(model): + assert name in self.state, f"Name {name} not existed, available names {self.state.keys()}" + val.copy_(self.state[name]) + + @contextmanager + def apply_and_restore(self, model): + if self.device: + old_state = EMAState.from_model(model, self.device) + else: + old_state = EMAState.from_model(model) + self.apply_to(model) + yield old_state + old_state.apply_to(model) + + def get_ema_model(self, model): + ret = copy.deepcopy(model) + self.apply_to(ret) + return ret + + @property + def device(self): + if not self.has_inited(): + return None + return next(iter(self.state.values())).device + + def to(self, device): + for name in self.state: + self.state[name] = self.state[name].to(device) + return self + + def has_inited(self): + return self.state + + def clear(self): + self.state.clear() + return self + + def get_model_state_iterator(self, model): + param_iter = model.named_parameters() + buffer_iter = model.named_buffers() + return itertools.chain(param_iter, buffer_iter) + + def state_dict(self): + return self.state + + def load_state_dict(self, state_dict, strict: bool = True): + self.clear() + for x, y in state_dict.items(): + self.state[x] = y + return torch.nn.modules.module._IncompatibleKeys(missing_keys=[], unexpected_keys=[]) + + def __repr__(self): + ret = f"EMAState(state=[{','.join(self.state.keys())}])" + return ret + + +class EMAUpdater: + def __init__( + self, + state: EMAState, + decay: float = 0.999, + warmups: int = 2000, + device: str = "", + ): + self.decay = decay + self.device = device + self.updates = 0 + self.state = state + if warmups > 0: + self.decay_fn = lambda x: decay * (1 - math.exp(-x / warmups)) + else: + self.decay_fn = lambda x: decay + + def init_state(self, model): + self.updates = 0 + self.state.clear() + self.state.save_from(model, self.device) + + def update(self, model): + with torch.no_grad(): + self.updates += 1 + decay = self.decay_fn(self.updates) + ema_param_list = [] + param_list = [] + for name, val in self.state.get_model_state_iterator(model): + ema_val = self.state.state[name] + if self.device: + val = val.to(self.device) + if val.dtype in [torch.float32, torch.float16]: + ema_param_list.append(ema_val) + param_list.append(val) + else: + ema_val.copy_(ema_val * decay + val * (1.0 - decay)) + self._ema_avg(ema_param_list, param_list, decay) + + def _ema_avg( + self, + averaged_model_parameters: List[torch.Tensor], + model_parameters: List[torch.Tensor], + decay: float, + ) -> None: + """ + Function to perform exponential moving average: + x_avg = alpha * x_avg + (1-alpha)* x_t + """ + torch._foreach_mul_(averaged_model_parameters, decay) + torch._foreach_add_(averaged_model_parameters, model_parameters, alpha=1 - decay) + + +def _remove_ddp(model): + from torch.nn.parallel import DistributedDataParallel + + if isinstance(model, DistributedDataParallel): + return model.module + return model + + +def build_model_ema(model): + model = _remove_ddp(model) + assert not hasattr(model, "ema_state"), "Name `ema_state` is reserved for model ema." + model.ema_state = EMAState() + logger.info("Using Model EMA.") + + +def get_ema_checkpointer(model): + model = _remove_ddp(model) + return {"ema_state": model.ema_state} + + +def get_model_ema_state(model): + """Return the ema state stored in `model`""" + model = _remove_ddp(model) + assert hasattr(model, "ema_state") + ema = model.ema_state + return ema + + +def apply_model_ema(model, state=None, save_current=False): + """Apply ema stored in `model` to model and returns a function to restore + the weights are applied + """ + model = _remove_ddp(model) + + if state is None: + state = get_model_ema_state(model) + + if save_current: + # save current model state + old_state = EMAState.from_model(model, state.device) + state.apply_to(model) + + if save_current: + return old_state + return None + + +@contextmanager +def apply_model_ema_and_restore(model, state=None): + """Apply ema stored in `model` to model and returns a function to restore + the weights are applied + """ + model = _remove_ddp(model) + + if state is None: + state = get_model_ema_state(model) + + old_state = EMAState.from_model(model, state.device) + state.apply_to(model) + yield old_state + old_state.apply_to(model) + + +class EMAHook(HookBase): + def __init__(self, model, decay: float = 0.999, warmup: int = 2000, device: str = ""): + model = _remove_ddp(model) + assert hasattr(model, "ema_state"), "Call `may_build_model_ema` first to initilaize the model ema" + self.model = model + self.ema = self.model.ema_state + self.device = device + self.ema_updater = EMAUpdater(self.model.ema_state, decay=decay, warmups=warmup, device=self.device) + + def before_train(self): + if self.ema.has_inited(): + self.ema.to(self.device) + else: + self.ema_updater.init_state(self.model) + + def after_train(self): + pass + + def before_step(self): + pass + + def after_step(self): + if not self.model.train: + return + self.ema_updater.update(self.model) diff --git a/focoos/trainer/solver/lr_scheduler.py b/focoos/trainer/solver/lr_scheduler.py new file mode 100755 index 00000000..5a9a64f0 --- /dev/null +++ b/focoos/trainer/solver/lr_scheduler.py @@ -0,0 +1,159 @@ +import math +from bisect import bisect_right +from typing import List + +import torch +from torch.optim.lr_scheduler import LRScheduler + +# NOTE: PyTorch's LR scheduler interface uses names that assume the LR changes +# only on epoch boundaries. We typically use iteration based schedules instead. +# As a result, "epoch" (e.g., as in self.last_epoch) should be understood to mean +# "iteration" instead. + +# FIX ME: ideally this would be achieved with a CombinedLRScheduler, separating +# MultiStepLR with WarmupLR but the current LRScheduler design doesn't allow it. + + +class BaseLRScheduler(LRScheduler): + def __init__( + self, + optimizer: torch.optim.Optimizer, + max_iters: int, + last_epoch: int = -1, + ): + self.max_iters = max_iters + super().__init__(optimizer, last_epoch) + + def get_lr(self) -> List[float]: + return self.base_lrs + + +class WarmupPolyLR(BaseLRScheduler): + """ + Poly learning rate schedule used to train DeepLab. + Paper: DeepLab: Semantic Image Segmentation with Deep Convolutional Nets, + Atrous Convolution, and Fully Connected CRFs. + Reference: https://github.com/tensorflow/models/blob/21b73d22f3ed05b650e85ac50849408dd36de32e/research/deeplab/utils/train_utils.py#L337 # noqa + """ + + def __init__( + self, + optimizer: torch.optim.Optimizer, + max_iters: int, + warmup_factor: float = 1.0, + warmup_iters: int = 0, + warmup_method: str = "linear", + last_epoch: int = -1, + power: float = 0.9, + constant_ending: float = 0.0, + ): + self.warmup_factor = warmup_factor + self.warmup_iters = warmup_iters + self.warmup_method = warmup_method + self.power = power + self.constant_ending = constant_ending + super().__init__(optimizer, max_iters, last_epoch) + + def get_lr(self) -> List[float]: + warmup_factor = _get_warmup_factor_at_iter( + self.warmup_method, self.last_epoch, self.warmup_iters, self.warmup_factor + ) + if self.constant_ending > 0 and warmup_factor == 1.0: + # Constant ending lr. + if math.pow((1.0 - self.last_epoch / self.max_iters), self.power) < self.constant_ending: + return [base_lr * self.constant_ending for base_lr in self.base_lrs] + return [ + base_lr * warmup_factor * math.pow((1.0 - self.last_epoch / self.max_iters), self.power) + for base_lr in self.base_lrs + ] + + +class WarmupMultiStepLR(BaseLRScheduler): + def __init__( + self, + optimizer: torch.optim.Optimizer, + max_iters: int, + milestones: List[float] = [], + gamma: float = 0.1, + warmup_factor: float = 1.0, + warmup_iters: int = 0, + warmup_method: str = "linear", + last_epoch: int = -1, + ): + if not list(milestones) == sorted(milestones): + raise ValueError( + "Milestones should be a list of increasing integers. Got {}", + milestones, + ) + self.milestones = [int(m * max_iters) for m in milestones] + self.gamma = gamma + self.warmup_factor = warmup_factor + self.warmup_iters = warmup_iters + self.warmup_method = warmup_method + super().__init__(optimizer, max_iters, last_epoch) + + def get_lr(self) -> List[float]: + warmup_factor = _get_warmup_factor_at_iter( + self.warmup_method, self.last_epoch, self.warmup_iters, self.warmup_factor + ) + return [ + base_lr * warmup_factor * self.gamma ** bisect_right(self.milestones, self.last_epoch) + for base_lr in self.base_lrs + ] + + +class WarmupCosineLR(BaseLRScheduler): + def __init__( + self, + optimizer: torch.optim.Optimizer, + max_iters: int, + warmup_factor: float = 1.0, + warmup_iters: int = 0, + warmup_method: str = "linear", + last_epoch: int = -1, + ): + self.warmup_factor = warmup_factor + self.warmup_iters = warmup_iters + self.warmup_method = warmup_method + super().__init__(optimizer, max_iters, last_epoch) + + def get_lr(self) -> List[float]: + warmup_factor = _get_warmup_factor_at_iter( + self.warmup_method, self.last_epoch, self.warmup_iters, self.warmup_factor + ) + # Different definitions of half-cosine with warmup are possible. For + # simplicity we multiply the standard half-cosine schedule by the warmup + # factor. An alternative is to start the period of the cosine at warmup_iters + # instead of at 0. In the case that warmup_iters << max_iters the two are + # very close to each other. + return [ + base_lr * warmup_factor * 0.5 * (1.0 + math.cos(math.pi * self.last_epoch / self.max_iters)) + for base_lr in self.base_lrs + ] + + +def _get_warmup_factor_at_iter(method: str, iter: int, warmup_iters: int, warmup_factor: float) -> float: + """ + Return the learning rate warmup factor at a specific iteration. + See :paper:`ImageNet in 1h` for more details. + + Args: + method (str): warmup method; either "constant" or "linear". + iter (int): iteration at which to calculate the warmup factor. + warmup_iters (int): the number of warmup iterations. + warmup_factor (float): the base warmup factor (the meaning changes according + to the method used). + + Returns: + float: the effective warmup factor at the given iteration. + """ + if iter >= warmup_iters: + return 1.0 + + if method == "constant": + return warmup_factor + elif method == "linear": + alpha = iter / warmup_iters + return warmup_factor * (1 - alpha) + alpha + else: + raise ValueError("Unknown warmup method: {}".format(method)) diff --git a/focoos/trainer/trainer.py b/focoos/trainer/trainer.py new file mode 100644 index 00000000..b913243a --- /dev/null +++ b/focoos/trainer/trainer.py @@ -0,0 +1,943 @@ +"""Unified training module for Focoos models. + +This module provides a simplified and unified training implementation that combines +the functionality of the original FocoosTrainer and the engine Trainer classes. +""" + +import os +import shutil +import time +import weakref +from collections.abc import Mapping +from datetime import datetime +from typing import Optional + +import numpy as np +import torch +from torch import GradScaler, autocast + +from focoos.data.datasets.map_dataset import MapDataset +from focoos.data.loaders import build_detection_test_loader, build_detection_train_loader +from focoos.hub.remote_model import RemoteModel +from focoos.models.focoos_model import BaseModelNN +from focoos.nn.layers.norm import FrozenBatchNorm2d +from focoos.ports import ArtifactName, ModelInfo, ModelStatus, Task, TrainerArgs, TrainingInfo +from focoos.processor.base_processor import Processor +from focoos.trainer.checkpointer import Checkpointer +from focoos.trainer.evaluation.evaluator import inference_on_dataset +from focoos.trainer.evaluation.get_eval import get_evaluator +from focoos.trainer.evaluation.utils import print_csv_format +from focoos.trainer.events import CommonMetricPrinter, EventStorage, JSONWriter, get_event_storage +from focoos.trainer.hooks import hook +from focoos.trainer.hooks.early_stop import EarlyStopException, EarlyStoppingHook +from focoos.trainer.hooks.sync_to_hub import SyncToHubHook +from focoos.trainer.hooks.visualization import VisualizationHook +from focoos.trainer.solver import ema +from focoos.trainer.solver.build import build_lr_scheduler, build_optimizer +from focoos.utils.distributed.dist import comm, create_ddp_model +from focoos.utils.env import seed_all_rng +from focoos.utils.logger import capture_all_output, get_logger +from focoos.utils.metrics import parse_metrics +from focoos.utils.system import get_system_info + +# Mapping of task types to their primary evaluation metrics +TASK_METRICS = { + Task.DETECTION.value: "bbox/AP", + Task.SEMSEG.value: "sem_seg/mIoU", + Task.INSTANCE_SEGMENTATION.value: "segm/AP", + Task.CLASSIFICATION.value: "classification/Accuracy", + # Task.PANOPTIC_SEGMENTATION.value: "panoptic_seg/PQ", +} + +logger = get_logger(__name__) + + +class FocoosTrainer: + def __init__( + self, + args: TrainerArgs, + model: BaseModelNN, + processor: Processor, + model_info: ModelInfo, + data_val: MapDataset, + data_train: Optional[MapDataset] = None, + remote_model: Optional[RemoteModel] = None, + ): + """Initialize the trainer. + + Args: + args: Training configuration + model: Model to train/evaluate + metadata: Model metadata/configuration + data_val: Validation dataset + data_train: Optional training dataset + """ + self.args = args + self.resume = args.resume + self.finished = False + + self.args.run_name = self.args.run_name.strip() + self.output_dir = os.path.join(self.args.output_dir, self.args.run_name) + # Setup logging and environment + self._setup_environment() + self.remote_model = remote_model + + # Setup model and data + self._setup_model_and_data(model, processor, model_info, data_train, data_val, args) + + # Setup training components + self._setup_training_components() + + def _setup_environment(self): + """Setup logging and environment variables.""" + + self.output_dir = os.path.join(self.args.output_dir, self.args.run_name) + if comm.is_main_process(): + os.makedirs(self.output_dir, exist_ok=True) + + _to_delete = ["metrics.json", "preview", "model_info.json"] + + # TODO: this delete the files if they already exist, but we should not do this during model.test() + if comm.is_main_process(): + for file in _to_delete: + if os.path.exists(os.path.join(self.output_dir, file)): + logger.warning(f"File {file} already exists in {self.output_dir}. Overwriting...") + if os.path.isdir(os.path.join(self.output_dir, file)): + shutil.rmtree(os.path.join(self.output_dir, file)) + else: + os.remove(os.path.join(self.output_dir, file)) + + logger.info(f"๐Ÿ“ Run name: {self.args.run_name} | Output dir: {self.output_dir}") + + logger.debug("Rank of current process: {}. World size: {}".format(comm.get_rank(), comm.get_world_size())) + get_system_info().pprint() + + seed_all_rng(None if self.args.seed < 0 else self.args.seed + comm.get_rank()) + torch.backends.cudnn.benchmark = False + + if self.args.ckpt_dir: + self.ckpt_dir = self.args.ckpt_dir + if comm.is_main_process(): + os.makedirs(self.ckpt_dir, exist_ok=True) + logger.info(f"[Checkpoints directory] {self.ckpt_dir}") + else: + self.ckpt_dir = self.output_dir + + def _setup_model_and_data( + self, + model: BaseModelNN, + processor: Processor, + model_info: ModelInfo, + data_train: Optional[MapDataset], + data_val: MapDataset, + args: TrainerArgs, + ): + """Setup model and data.""" + # Setup Model + self.model = model + self.processor = processor.train() + self.model_info = model_info + self.model_info.weights_uri = os.path.join(self.output_dir, "model_final.pth") + self.checkpoint = self.args.init_checkpoint + # Setup data + self.data_train = data_train + self.data_val = data_val + + # Get task and num_classes from validation dataset + self.num_classes = data_val.dataset.metadata.num_classes + self.task = data_val.dataset.metadata.task + + # Apply model modifications + if self.args.freeze_bn: + self.model = FrozenBatchNorm2d.convert_frozen_batchnorm(self.model) + + # Setup DDP if needed + if comm.get_world_size() > 1: + self.model = torch.nn.SyncBatchNorm.convert_sync_batchnorm(self.model) + self.model.to(self.args.device) + + # Setup EMA if enabled + if self.args.ema_enabled and not hasattr(self.model, "ema_state"): + ema.build_model_ema(self.model) + + # Setup evaluator + self.data_evaluator = get_evaluator(dataset_dict=self.data_val.dataset, task=self.task) + + if data_train: + logger.info( + f"๐Ÿ“Š [TRAIN DATASET: {len(data_train)} samples] {str(data_train.dataset.metadata)} | " + f"[Train augmentations] {data_train.mapper.augmentations}" + ) + # Log dataset info + logger.info( + f"๐Ÿ“Š [VALIDATION DATASET: {len(data_val)} samples] Classes: {data_val.dataset.metadata.num_classes} | " + f"Augmentations: {data_val.mapper.augmentations} | " + f"Evaluator: {type(self.data_evaluator)} ๐Ÿ”" + ) + + # Save metadata + if comm.get_rank() == 0: + self.model_info.dump_json(os.path.join(self.output_dir, "model_info.json")) + + def _setup_training_components(self): + """Setup training components like optimizer, scheduler, etc.""" + # This will be called during train() method + pass + + def _store_model(self, save_file): + """Store model weights to file. + + Args: + save_file: Path to save model + """ + data = {} + if self.args.ema_enabled: + ema.apply_model_ema(self.model) + data["model"] = self.model.state_dict() + save_file = os.path.join(self.output_dir, save_file) + logger.info("Saving final model to {}".format(save_file)) + torch.save(data, save_file) + self.model_info.weights_uri = save_file + + def _restore_best_model(self, name: str = "model_best.pth"): + """Restore best model from checkpoint. + + Args: + name: Checkpoint filename + + Returns: + bool: Whether restore was successful + """ + best_path = os.path.join(self.ckpt_dir, name) + if os.path.exists(best_path): + state_dict = torch.load(best_path, weights_only=True) + self.model.load_state_dict(state_dict["model"]) + if self.args.ema_enabled and "ema_state" in state_dict: + self.model.ema_state.load_state_dict(state_dict["ema_state"]) # type: ignore + return True + return False + + def finish(self): + """Clean up and finalize training/testing.""" + if comm.get_rank() == 0: + logger.info("๐Ÿ End of training.") + # save model to model_final.pth - if EMA, store it. + + if self.finished: + restored = self._restore_best_model() + if restored: + logger.info("Restored best model from checkpoint.") + if self.args.ema_enabled: + ema.apply_model_ema(self.model, save_current=True) + os.remove(os.path.join(self.ckpt_dir, "model_best.pth")) + self._store_model("model_final.pth") + try: + parsed_metrics = parse_metrics(os.path.join(self.output_dir, "metrics.json")) + parsed_metrics.valid_metrics = [] + parsed_metrics.train_metrics = [] + self.model_info.val_metrics = parsed_metrics.best_valid_metric + except Exception as e: + logger.warning(f"Error parsing metrics.json: {e}") + pass + + self._update_training_info_and_dump(ModelStatus.TRAINING_COMPLETED, self.args.max_iters) + + def _do_eval(self, model): + """Internal method to evaluate model. + + Args: + model: Model to evaluate + + Returns: + dict: Evaluation metrics + """ + data_loader = build_detection_test_loader( + self.data_val, + num_workers=self.args.workers, + ) + + ret = inference_on_dataset( + model, + processor=self.processor, + data_loader=data_loader, + evaluator=self.data_evaluator, + ) + print_csv_format(ret) + return ret + + def _val(self): + """Run model evaluation on validation set. + + Returns: + dict: Evaluation metrics + """ + if self.args.ema_enabled: + logger.info("๐Ÿ” Run evaluation with EMA.") + with ema.apply_model_ema_and_restore(self.model): + eval_res = self._do_eval(self.model) + else: + logger.info("๐Ÿ” Run evaluation without EMA.") + eval_res = self._do_eval(self.model) + + if comm.get_rank() == 0: + key, value = TASK_METRICS[self.task.value].split("/") + storage = get_event_storage() + iteration = storage.iteration + raw_metrics = _add_prefix(eval_res[key], key) + raw_metrics["iteration"] = iteration + + if ( + self.model_info.val_metrics is None + or raw_metrics[TASK_METRICS[self.task.value]] + > self.model_info.val_metrics[TASK_METRICS[self.task.value]] + ): + self.model_info.val_metrics = raw_metrics + self.model_info.updated_at = datetime.now().isoformat() + logger.info(f"โœจ New best validation metric: {raw_metrics[TASK_METRICS[self.task.value]]}") + return eval_res + + def _register_hooks(self, trainer, model, checkpointer, optim, scheduler, args): + """Register hooks for the trainer. + + Args: + trainer: The trainer instance + model: The model + checkpointer: The checkpointer + optim: The optimizer + scheduler: The learning rate scheduler + args: Training arguments + """ + trainer.register_hooks( + [ + hook.IterationTimer(), + hook.LRScheduler(optimizer=optim, scheduler=scheduler), + ( + ema.EMAHook( + model, + decay=args.ema_decay, + warmup=args.ema_warmup if not args.resume else 0, + device=args.device, + ) + if args.ema_enabled + else None + ), + hook.EvalHook(args.eval_period, lambda: self._val()), + ( # this should be after the eval hook to print the metrics + hook.PeriodicWriter( + [ + CommonMetricPrinter(args.max_iters), + JSONWriter( + os.path.join(self.ckpt_dir, "metrics.json"), + ), + # TensorboardXWriter(self.output_dir), + ], + period=args.log_period, + ) + if comm.is_main_process() + else None + ), + EarlyStoppingHook( + enabled=args.early_stop, + eval_period=args.eval_period, + patience=args.patience, + val_metric=TASK_METRICS[self.task.value], + mode="max", + ), + ] + ) + + if comm.is_main_process(): + trainer.register_hooks( + [ + hook.BestCheckpointer( + checkpointer=checkpointer, + eval_period=args.eval_period, + val_metric=TASK_METRICS[self.task.value], + mode="max", + ), + hook.PeriodicCheckpointer( + checkpointer, + period=args.checkpointer_period, + max_to_keep=args.checkpointer_max_to_keep, + ), + VisualizationHook( + model=self.model, # type: ignore + processor=self.processor, + dataset=self.data_val, + period=self.args.eval_period, + n_sample=self.args.samples, + output_dir=self.output_dir, + ), + ] + ) + if self.args.sync_to_hub and self.remote_model: + trainer.register_hooks( + [ + SyncToHubHook( + remote_model=self.remote_model, + model_info=self.model_info, + output_dir=self.output_dir, + sync_period=60, + ), + ] + ) + + def train(self): + """Train the model using the configured settings.""" + args = self.args + model = self.model + + assert self.data_train is not None, "Train dataset is required for training" + + # Setup Optimizer + optim = build_optimizer( + name=self.args.optimizer, + learning_rate=self.args.learning_rate, + weight_decay=self.args.weight_decay, + model=model, + weight_decay_norm=self.args.weight_decay_norm, + weight_decay_embed=self.args.weight_decay_embed, + backbone_multiplier=self.args.backbone_multiplier, + decoder_multiplier=self.args.decoder_multiplier, + head_multiplier=self.args.head_multiplier, + clip_gradients=self.args.clip_gradients, + extra=self.args.optimizer_extra, + ) + scheduler = build_lr_scheduler( + name=self.args.scheduler, + max_iters=self.args.max_iters, + optimizer=optim, + extra=self.args.scheduler_extra, + ) + + # Setup dataset + train_loader = build_detection_train_loader( + dataset=self.data_train, + total_batch_size=args.batch_size, + num_workers=args.workers, + ) + + # Handle Multi-GPU Training + model = create_ddp_model( + model, + broadcast_buffers=self.args.ddp_broadcast_buffers, + find_unused_parameters=self.args.ddp_find_unused, + ) + + # Setup Trainer + trainer_loop = TrainerLoop( + model=model, + processor=self.processor, + dataloader=train_loader, + optimizer=optim, + amp=args.amp_enabled, + clip_gradient=args.clip_gradients, + gather_metric_period=args.gather_metric_period, + zero_grad_before_forward=args.zero_grad_before_forward, + ) + + # Setup Checkpointer + checkpointer = Checkpointer( + model, # type: ignore + save_dir=self.ckpt_dir, + trainer=trainer_loop, + **ema.get_ema_checkpointer(model) if args.ema_enabled else {}, + ) + + self._register_hooks(trainer_loop, model, checkpointer, optim, scheduler, args) + + # Load checkpoint if needed + if self.checkpoint: + checkpointer.resume_or_load(path=self.checkpoint, resume=args.resume) + else: + checkpointer.resume_or_load(path="", resume=args.resume) + + if self.args.resume and checkpointer.has_checkpoint(): + # The checkpoint stores the training iteration that just finished, thus we start + # at the next iteration + start_iter = trainer_loop.iter + 1 + else: + start_iter = 0 + + output_lines = [ + f"๐Ÿš€ Starting training from iteration {start_iter}", + "========== ๐Ÿ”ง Main Hyperparameters ๐Ÿ”ง ==========", + f" - max_iter: {self.args.max_iters}", + f" - batch_size: {self.args.batch_size}", + f" - learning_rate: {self.args.learning_rate}", + " - resolution: !TODO", + f" - optimizer: {self.args.optimizer}", + f" - scheduler: {self.args.scheduler}", + f" - weight_decay: {self.args.weight_decay}", + f" - ema_enabled: {self.args.ema_enabled}", + "================================================", + ] + logger.info("\n".join(output_lines)) + + self._update_training_info_and_dump(ModelStatus.TRAINING_RUNNING) + trainer_loop.train(start_iter=start_iter, max_iter=args.max_iters) + self.finished = True + self.finish() + + def test(self, restore_best: bool = False): + """Run model evaluation on test set. + + Args: + restore_best: Whether to restore best checkpoint before testing + + Returns: + dict: Evaluation metrics + """ + args = self.args + model = self.model + + model = create_ddp_model(model) + + if restore_best: + # Setup Checkpointer to recover trained model or load from scratch + checkpointer = Checkpointer( + model=model, # type: ignore + save_dir=self.output_dir, + **ema.get_ema_checkpointer(model) if args.ema_enabled else {}, + ) + checkpointer.resume_or_load(path=self.checkpoint or "", resume=args.resume) + if args.ema_enabled: + ema.apply_model_ema(model) + + eval_result = self._do_eval(model) + + if comm.get_rank() == 0: + key, value = TASK_METRICS[self.task.value].split("/") + raw_metrics = _add_prefix(eval_result[key], key) + if ( + self.model_info.val_metrics is None + or raw_metrics[TASK_METRICS[self.task.value]] + > self.model_info.val_metrics[TASK_METRICS[self.task.value]] + ): + self.model_info.val_metrics = raw_metrics + + self.finished = True + self.finish() + return eval_result + + def _update_training_info_and_dump(self, new_status: ModelStatus, detail: Optional[str] = None): + self.model_info.status = new_status + self.model_info.updated_at = datetime.now().isoformat() + if self.model_info.training_info is None: + self.model_info.training_info = TrainingInfo() + + self.model_info.training_info.main_status = new_status + if self.model_info.training_info.status_transitions is None: + self.model_info.training_info.status_transitions = [] + if self.model_info.training_info.main_status != new_status: + self.model_info.training_info.main_status = new_status + + if new_status in [ModelStatus.TRAINING_ERROR, ModelStatus.TRAINING_COMPLETED]: + self.model_info.training_info.end_time = datetime.now().isoformat() + + if new_status == ModelStatus.TRAINING_ERROR: + self.model_info.training_info.failure_reason = detail + + self.model_info.training_info.status_transitions.append( + dict( + status=new_status, + timestamp=datetime.now().isoformat(), + detail=detail, + ) + ) + if comm.is_main_process(): + self.model_info.dump_json(os.path.join(self.output_dir, ArtifactName.INFO)) + + +class TrainerLoop: + """Unified training loop implementation. + + This class implements the core training loop functionality, combining + the features of SimpleTrainer and Trainer with AMP support. + """ + + def __init__( + self, + model, + processor, + dataloader, + optimizer, + amp=False, + clip_gradient: float = 0.0, + grad_scaler=None, + gather_metric_period=1, + zero_grad_before_forward=False, + ): + """Initialize the trainer loop. + + Args: + model: The model to train + dataloader: The data loader + optimizer: The optimizer + amp: Whether to use automatic mixed precision + clip_gradient: Gradient clipping value + grad_scaler: Gradient scaler for AMP + gather_metric_period: How often to gather metrics + zero_grad_before_forward: Whether to zero gradients before forward pass + """ + self._hooks = [] + self.iter = 0 + self.start_iter = 0 + self.max_iter = 0 + self.storage = None + + # Set model to training mode + model.train() + + self.model = model + self.processor = processor + self.data_loader = dataloader + self._data_loader_iter_obj = None + self.optimizer = optimizer + self.gather_metric_period = gather_metric_period + self.zero_grad_before_forward = zero_grad_before_forward + + # AMP setup + if amp: + if grad_scaler is None: + # the init_scale avoids the first step to be too large + # and the scheduler.step() warning + grad_scaler = GradScaler(init_scale=2**10) + self.grad_scaler = grad_scaler + self.amp = amp + self.precision = torch.float16 + else: + self.amp = False + self.precision = torch.float32 + + # Gradient clipping + self.clip_gradient = clip_gradient + + def register_hooks(self, hooks): + """Register hooks for the trainer. + + Args: + hooks: List of hooks to register + """ + hooks = [h for h in hooks if h is not None] + for h in hooks: + h.trainer = weakref.proxy(self) + self._hooks.extend(hooks) + + def train(self, start_iter: int, max_iter: int): + """Train the model. + + Args: + start_iter: Starting iteration + max_iter: Maximum iteration + """ + self.iter = self.start_iter = start_iter + self.max_iter = max_iter + + with EventStorage(start_iter) as self.storage: + try: + self.before_train() + for self.iter in range(start_iter, max_iter): + self.before_step() + self.run_step() + self.after_step() + self.iter += 1 + except EarlyStopException as e: + logger.info(f"๐Ÿšจ Early stopping triggered: {e}") + except Exception as e: + logger.error(f"๐Ÿšจ Exception during training: {e}") + raise e + finally: + # Verifica se c'รจ stata un'eccezione prima di eseguire after_train + self.after_train() + + def before_train(self): + """Called before training starts.""" + for h in self._hooks: + h.before_train() + + def after_train(self): + """Called after training ends.""" + if self.storage is not None: + self.storage.iter = self.iter + for h in self._hooks: + h.after_train() + + def before_step(self): + """Called before each training step.""" + if self.storage is not None: + self.storage.iter = self.iter + for h in self._hooks: + h.before_step() + + def after_backward(self): + """Called after backward pass.""" + for h in self._hooks: + h.after_backward() + + def after_step(self): + """Called after each training step.""" + for h in self._hooks: + h.after_step() + + def run_step(self): + """Run a single training step.""" + assert self.model.training, "[UnifiedTrainerLoop] model was changed to eval mode!" + + start = time.perf_counter() + data = next(self._data_loader_iter) + data_time = time.perf_counter() - start + + if self.zero_grad_before_forward: + self.optimizer.zero_grad() + + if self.amp: + assert torch.cuda.is_available(), "[UnifiedTrainerLoop] CUDA is required for AMP training!" + with autocast(enabled=self.amp, dtype=self.precision, device_type="cuda"): + # we need to have preprocess data here + images, targets = self.processor.preprocess(data, dtype=self.precision, device=self.model.device) + loss_dict = self.model(images, targets).loss + if isinstance(loss_dict, torch.Tensor): + losses = loss_dict + loss_dict = {"total_loss": loss_dict} + else: + losses = sum(loss_dict.values()) + else: + loss_dict = self.model(data).loss + if isinstance(loss_dict, torch.Tensor): + losses = loss_dict + loss_dict = {"total_loss": loss_dict} + else: + losses = sum(loss_dict.values()) + + if not self.zero_grad_before_forward: + self.optimizer.zero_grad() + + if self.amp: + self.grad_scaler.scale(losses).backward() # type: ignore + if self.clip_gradient > 0.0: + self.grad_scaler.unscale_(self.optimizer) + self.clip_grads(self.model.parameters()) + else: + losses.backward() # type: ignore + if self.clip_gradient > 0.0: + self.clip_grads(self.model.parameters()) + + self.after_backward() + self._write_metrics(loss_dict, data_time) + + if self.amp: + self.grad_scaler.step(self.optimizer) + self.grad_scaler.update() + else: + self.optimizer.step() + + @property + def _data_loader_iter(self): + """Get the data loader iterator.""" + if self._data_loader_iter_obj is None: + self._data_loader_iter_obj = iter(self.data_loader) + return self._data_loader_iter_obj + + def clip_grads(self, params): + """Clip gradients. + + Args: + params: Parameters to clip gradients for + + Returns: + float: Total norm of the parameters + """ + params = list(filter(lambda p: p.requires_grad and p.grad is not None, params)) + if len(params) > 0: + return torch.nn.utils.clip_grad_norm_(parameters=params, max_norm=self.clip_gradient) + return 0.0 + + def _write_metrics( + self, + loss_dict: Mapping[str, torch.Tensor], + data_time: float, + prefix: str = "", + iter: Optional[int] = None, + ) -> None: + """Write metrics to storage. + + Args: + loss_dict: Dictionary of losses + data_time: Time taken by data loading + prefix: Prefix for metric names + iter: Current iteration + """ + logger = get_logger(__name__) + + iter = self.iter if iter is None else iter + if (iter + 1) % self.gather_metric_period == 0: + try: + self.write_metrics(loss_dict, data_time, iter, prefix) + except Exception: + logger.exception("Exception in writing metrics: ") + raise + + @staticmethod + def write_metrics( + loss_dict: Mapping[str, torch.Tensor], + data_time: float, + cur_iter: int, + prefix: str = "", + ) -> None: + """Write metrics to storage. + + Args: + loss_dict: Dictionary of losses + data_time: Time taken by data loading + cur_iter: Current iteration + prefix: Prefix for metric names + """ + metrics_dict = {k: v.detach().cpu().item() for k, v in loss_dict.items()} + metrics_dict["data_time"] = data_time + + storage = get_event_storage() + # Keep track of data time per rank + storage.put_scalar("rank_data_time", data_time, cur_iter=cur_iter) + + # Gather metrics among all workers for logging + all_metrics_dict = comm.gather(metrics_dict) + + if comm.is_main_process(): + # data_time among workers can have high variance. The actual latency + # caused by data_time is the maximum among workers. + data_time = np.max([x.pop("data_time") for x in all_metrics_dict]) # type: ignore + storage.put_scalar("data_time", data_time, cur_iter=cur_iter) + + # average the rest metrics + metrics_dict = {k: np.mean([x[k] for x in all_metrics_dict]) for k in all_metrics_dict[0].keys()} # type: ignore + total_losses_reduced = sum(metrics_dict.values()) # type: ignore + if not np.isfinite(total_losses_reduced): + raise FloatingPointError( + f"Loss became infinite or NaN at iteration={cur_iter}!\nloss_dict = {metrics_dict}" + ) + + storage.put_scalar("{}total_loss".format(prefix), total_losses_reduced, cur_iter=cur_iter) + if len(metrics_dict) > 1: + storage.put_scalars(cur_iter=cur_iter, **metrics_dict) + + def state_dict(self): + """Get the state dict of the trainer. + + Returns: + dict: State dict + """ + ret = {"iteration": self.iter} + hooks_state = {} + for h in self._hooks: + sd = h.state_dict() + if sd: + name = type(h).__qualname__ + if name in hooks_state: + continue + hooks_state[name] = sd + if hooks_state: + ret["hooks"] = hooks_state # type: ignore + ret["optimizer"] = self.optimizer.state_dict() + if self.amp: + ret["grad_scaler"] = self.grad_scaler.state_dict() # type: ignore + return ret + + def load_state_dict(self, state_dict): + """Load the state dict of the trainer. + + Args: + state_dict: State dict to load + """ + self.iter = state_dict["iteration"] + for key, value in state_dict.get("hooks", {}).items(): + for h in self._hooks: + try: + name = type(h).__qualname__ + except AttributeError: + continue + if name == key: + h.load_state_dict(value) # type: ignore + break + else: + logger.warning(f"Cannot find the hook '{key}', its state_dict is ignored.") + self.optimizer.load_state_dict(state_dict["optimizer"]) + if self.amp and "grad_scaler" in state_dict: + self.grad_scaler.load_state_dict(state_dict["grad_scaler"]) # type: ignore + + +def _add_prefix(metric, key): + """Add prefix to metric keys. + + Args: + metric: Metric dictionary + key: Prefix to add + + Returns: + dict: Metric dictionary with prefix + """ + return {f"{key}/{k}": v for k, v in metric.items()} + + +def run_train( + train_args: TrainerArgs, + data_train: MapDataset, + data_val: MapDataset, + image_model: BaseModelNN, + processor: Processor, + model_info: ModelInfo, # type: ignore # noqa: F821 + remote_model: Optional[RemoteModel] = None, +): + """Run model training. + + Args: + train_args: Training configuration + data_train: Training dataset + data_val: Validation dataset + image_model: Model to train + metadata: Model metadata/configuration + rank: Rank of the process + Returns: + tuple: (trained model, updated metadata) + """ + + rank = comm.get_local_rank() + log_path = os.path.join(train_args.output_dir, train_args.run_name.strip(), "log.txt") + with capture_all_output(log_path=log_path, rank=rank): + trainer = FocoosTrainer( + args=train_args, + model=image_model, + processor=processor, + model_info=model_info, + data_train=data_train, + data_val=data_val, + remote_model=remote_model, + ) + trainer.train() + + return image_model, model_info + + +def run_test( + train_args: TrainerArgs, + data_val: MapDataset, + image_model: BaseModelNN, + processor: Processor, + model_info: ModelInfo, + remote_model: Optional[RemoteModel] = None, +): + rank = comm.get_local_rank() + + log_path = os.path.join(train_args.output_dir, train_args.run_name.strip(), "test_log.txt") + with capture_all_output(log_path=log_path, rank=rank): + trainer = FocoosTrainer( + args=train_args, + model=image_model, + processor=processor, + model_info=model_info, + data_val=data_val, + remote_model=remote_model, + ) + trainer.test() + + return image_model, model_info diff --git a/focoos/utils/__init__.py b/focoos/utils/__init__.py new file mode 100644 index 00000000..6f4cc625 --- /dev/null +++ b/focoos/utils/__init__.py @@ -0,0 +1,3 @@ +from focoos.utils.logger import get_logger + +__all__ = ["get_logger"] diff --git a/focoos/utils/box.py b/focoos/utils/box.py new file mode 100644 index 00000000..a12fd997 --- /dev/null +++ b/focoos/utils/box.py @@ -0,0 +1,89 @@ +import torch +from torchvision.ops.boxes import box_area + + +def normalize_boxes(x, size): + # assume xyhw or xyxy; normalize from size to [0-1] + x_c, y_c, w, h = x.unbind(-1) + b = [(x_c / size[1]), (y_c / size[0]), (w / size[1]), (h / size[0])] + return torch.stack(b, dim=-1) + + +def box_cxcywh_to_xyxy(x): + x_c, y_c, w, h = x.unbind(-1) + b = [(x_c - 0.5 * w), (y_c - 0.5 * h), (x_c + 0.5 * w), (y_c + 0.5 * h)] + return torch.stack(b, dim=-1) + + +def box_xyxy_to_cxcywh(x): + x0, y0, x1, y1 = x.unbind(-1) + b = [(x0 + x1) / 2, (y0 + y1) / 2, (x1 - x0), (y1 - y0)] + return torch.stack(b, dim=-1) + + +# modified from torchvision to also return the union +def box_iou(boxes1, boxes2): + area1 = box_area(boxes1) + area2 = box_area(boxes2) + + lt = torch.max(boxes1[:, None, :2], boxes2[:, :2]) # [N,M,2] + rb = torch.min(boxes1[:, None, 2:], boxes2[:, 2:]) # [N,M,2] + + wh = (rb - lt).clamp(min=0) # [N,M,2] + inter = wh[:, :, 0] * wh[:, :, 1] # [N,M] + + union = area1[:, None] + area2 - inter + + iou = inter / union + return iou, union + + +def generalized_box_iou(boxes1, boxes2): + """ + Generalized IoU from https://giou.stanford.edu/ + + The boxes should be in [x0, y0, x1, y1] format + + Returns a [N, M] pairwise matrix, where N = len(boxes1) + and M = len(boxes2) + """ + # degenerate boxes gives inf / nan results + # so do an early check + assert (boxes1[:, 2:] >= boxes1[:, :2]).all() + assert (boxes2[:, 2:] >= boxes2[:, :2]).all() + iou, union = box_iou(boxes1, boxes2) + + lt = torch.min(boxes1[:, None, :2], boxes2[:, :2]) + rb = torch.max(boxes1[:, None, 2:], boxes2[:, 2:]) + + wh = (rb - lt).clamp(min=0) # [N,M,2] + area = wh[:, :, 0] * wh[:, :, 1] + + return iou - (area - union) / (area + 1e-5) + + +def masks_to_boxes(masks): + """Compute the bounding boxes around the provided masks + + The masks should be in format [N, H, W] where N is the number of masks, (H, W) are the spatial dimensions. + + Returns a [N, 4] tensors, with the boxes in xyxy format + """ + if masks.numel() == 0: + return torch.zeros((0, 4), device=masks.device) + + h, w = masks.shape[-2:] + + y = torch.arange(0, h, dtype=torch.float, device=masks.device) + x = torch.arange(0, w, dtype=torch.float, device=masks.device) + y, x = torch.meshgrid(y, x) + + x_mask = masks * x.unsqueeze(0) + x_max = x_mask.flatten(1).max(-1)[0] + x_min = x_mask.masked_fill(~(masks.bool()), 1e8).flatten(1).min(-1)[0] + + y_mask = masks * y.unsqueeze(0) + y_max = y_mask.flatten(1).max(-1)[0] + y_min = y_mask.masked_fill(~(masks.bool()), 1e8).flatten(1).min(-1)[0] + + return torch.stack([x_min, y_min, x_max, y_max], 1) diff --git a/focoos/utils/checkpoint.py b/focoos/utils/checkpoint.py new file mode 100644 index 00000000..1e50ec93 --- /dev/null +++ b/focoos/utils/checkpoint.py @@ -0,0 +1,145 @@ +from collections import defaultdict +from typing import Any, Dict, List, Tuple + +from termcolor import colored + +from focoos.utils.logger import get_logger + +logger = get_logger("Checkpoint") + + +def strip_prefix_if_present(state_dict: Dict[str, Any], prefix: str) -> None: + """ + Strip the prefix in metadata, if any. + Args: + state_dict (OrderedDict): a state-dict to be loaded to the model. + prefix (str): prefix. + """ + keys = sorted(state_dict.keys()) + if not all(len(key) == 0 or key.startswith(prefix) for key in keys): + return + + for key in keys: + newkey = key[len(prefix) :] + state_dict[newkey] = state_dict.pop(key) + + # also strip the prefix in metadata, if any.. + try: + metadata = state_dict._metadata # type: ignore + except AttributeError: + pass + else: + for key in list(metadata.keys()): + # for the metadata dict, the key can be: + # '': for the DDP module, which we want to remove. + # 'module': for the actual model. + # 'module.xx.xx': for the rest. + + if len(key) == 0: + continue + newkey = key[len(prefix) :] + metadata[newkey] = metadata.pop(key) + + +def _group_checkpoint_keys(keys: List[str]) -> Dict[str, List[str]]: + """ + Group keys based on common prefixes. A prefix is the string up to the final + "." in each key. + Args: + keys (list[str]): list of parameter names, i.e. keys in the model + checkpoint dict. + Returns: + dict[list]: keys with common prefixes are grouped into lists. + """ + groups = defaultdict(list) + for key in keys: + pos = key.rfind(".") + if pos >= 0: + head, tail = key[:pos], [key[pos + 1 :]] + else: + head, tail = key, [] + groups[head].extend(tail) + return groups + + +def _group_to_str(group: List[str]) -> str: + """ + Format a group of parameter name suffixes into a loggable string. + Args: + group (list[str]): list of parameter name suffixes. + Returns: + str: formated string. + """ + if len(group) == 0: + return "" + + if len(group) == 1: + return "." + group[0] + + return ".{" + ", ".join(sorted(group)) + "}" + + +def get_missing_parameters_message(keys: List[str]) -> str: + """ + Get a logging-friendly message to report parameter names (keys) that are in + the model but not found in a checkpoint. + Args: + keys (list[str]): List of keys that were not found in the checkpoint. + Returns: + str: message. + """ + groups = _group_checkpoint_keys(keys) + msg_per_group = sorted(k + _group_to_str(v) for k, v in groups.items()) + msg = "Some model parameters or buffers are not found in the checkpoint:\n" + msg += "\n".join([colored(x, "blue") for x in msg_per_group]) + return msg + + +def get_unexpected_parameters_message(keys: List[str]) -> str: + """ + Get a logging-friendly message to report parameter names (keys) that are in + the checkpoint but not found in the model. + Args: + keys (list[str]): List of keys that were not found in the model. + Returns: + str: message. + """ + groups = _group_checkpoint_keys(keys) + msg = "The checkpoint state_dict contains keys that are not used by the model:\n" + msg += "\n".join(" " + colored(k + _group_to_str(v), "magenta") for k, v in groups.items()) + return msg + + +class IncompatibleKeys: + def __init__( + self, + missing_keys: List[str], + unexpected_keys: List[str], + incorrect_shapes: List[Tuple[str, Tuple[int], Tuple[int]]], + ): + self.missing_keys = missing_keys + self.unexpected_keys = unexpected_keys + self.incorrect_shapes = incorrect_shapes + + def __str__(self): + return f"Missing keys: {self.missing_keys}\nUnexpected keys: {self.unexpected_keys}\nIncorrect shapes: {self.incorrect_shapes}" + + def __repr__(self): + return self.__str__() + + def log_incompatible_keys(self) -> None: + """ + Log information about the incompatible keys returned by ``_load_model``. + """ + for k, shape_checkpoint, shape_model in self.incorrect_shapes: + logger.warning( + "Skip loading parameter '{}' to the model due to incompatible " + "shapes: {} in the checkpoint but {} in the " + "model! You might want to double check if this is expected.".format(k, shape_checkpoint, shape_model) + ) + if self.missing_keys: + missing_keys = self.missing_keys + if missing_keys: + logger.warning(get_missing_parameters_message(missing_keys)) + if self.unexpected_keys: + logger.warning(get_unexpected_parameters_message(self.unexpected_keys)) diff --git a/focoos/utils/cmap_builder.py b/focoos/utils/cmap_builder.py new file mode 100644 index 00000000..4426aa4b --- /dev/null +++ b/focoos/utils/cmap_builder.py @@ -0,0 +1,26 @@ +from typing import Optional + +import numpy as np + + +def cmap_builder(classes: Optional[list] = None, normalized: bool = False) -> np.ndarray: + classes = list(range(256)) if classes is None else classes + + def bitget(byteval, idx): + return (byteval & (1 << idx)) != 0 + + dtype = "float32" if normalized else "uint8" + cmap = np.zeros((256, 3), dtype=dtype) + 160 + for idx in classes: + r = g = b = 0 + c = idx + for j in range(8): + r = r | (bitget(c, 0) << 7 - j) + g = g | (bitget(c, 1) << 7 - j) + b = b | (bitget(c, 2) << 7 - j) + c = c >> 3 + + cmap[idx] = np.array([r, g, b]) + + cmap = cmap / 255 if normalized else cmap + return cmap diff --git a/focoos/utils/distributed/__init__.py b/focoos/utils/distributed/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/focoos/utils/distributed/comm.py b/focoos/utils/distributed/comm.py new file mode 100644 index 00000000..5256626e --- /dev/null +++ b/focoos/utils/distributed/comm.py @@ -0,0 +1,239 @@ +# Copyright (c) Facebook, Inc. and its affiliates. +""" +This file contains primitives for multi-gpu communication. +This is useful when doing distributed training. +""" + +import functools + +import numpy as np +import torch +import torch.distributed as tdist + +_LOCAL_PROCESS_GROUP = None +_MISSING_LOCAL_PG_ERROR = ( + "Local process group is not yet created! Please use detectron2's `launch()` " + "to start processes and initialize pytorch process group. If you need to start " + "processes in other ways, please call comm.create_local_process_group(" + "num_workers_per_machine) after calling torch.distributed.init_process_group()." +) + + +def get_world_size() -> int: + if not tdist.is_available(): + return 1 + if not tdist.is_initialized(): + return 1 + return tdist.get_world_size() + + +def get_rank() -> int: + if not tdist.is_available(): + return 0 + if not tdist.is_initialized(): + return 0 + return tdist.get_rank() + + +@functools.lru_cache() +def create_local_process_group(num_workers_per_machine: int) -> None: + """ + Create a process group that contains ranks within the same machine. + + launch() in trainer/trainer.py will call this function. If you start + workers without launch(), you'll have to also call this. Otherwise utilities + like `get_local_rank()` will not work. + + This function contains a barrier. All processes must call it together. + + Args: + num_workers_per_machine: the number of worker processes per machine. Typically + the number of GPUs. + """ + global _LOCAL_PROCESS_GROUP + assert _LOCAL_PROCESS_GROUP is None + assert get_world_size() % num_workers_per_machine == 0 + num_machines = get_world_size() // num_workers_per_machine + machine_rank = get_rank() // num_workers_per_machine + for i in range(num_machines): + ranks_on_i = list(range(i * num_workers_per_machine, (i + 1) * num_workers_per_machine)) + pg = tdist.new_group(ranks_on_i) + if i == machine_rank: + _LOCAL_PROCESS_GROUP = pg + + +def get_local_process_group(): + """ + Returns: + A torch process group which only includes processes that are on the same + machine as the current process. This group can be useful for communication + within a machine, e.g. a per-machine SyncBN. + """ + assert _LOCAL_PROCESS_GROUP is not None, _MISSING_LOCAL_PG_ERROR + return _LOCAL_PROCESS_GROUP + + +def get_local_rank() -> int: + """ + Returns: + The rank of the current process within the local (per-machine) process group. + """ + if not tdist.is_available(): + return 0 + if not tdist.is_initialized(): + return 0 + assert _LOCAL_PROCESS_GROUP is not None, _MISSING_LOCAL_PG_ERROR + return tdist.get_rank(group=_LOCAL_PROCESS_GROUP) + + +def get_local_size() -> int: + """ + Returns: + The size of the per-machine process group, + i.e. the number of processes per machine. + """ + if not tdist.is_available(): + return 1 + if not tdist.is_initialized(): + return 1 + assert _LOCAL_PROCESS_GROUP is not None, _MISSING_LOCAL_PG_ERROR + return tdist.get_world_size(group=_LOCAL_PROCESS_GROUP) + + +def is_main_process() -> bool: + return get_rank() == 0 + + +def synchronize(): + """ + Helper function to synchronize (barrier) among all processes when + using distributed training + """ + if not tdist.is_available(): + return + if not tdist.is_initialized(): + return + world_size = tdist.get_world_size() + if world_size == 1: + return + if tdist.get_backend() == tdist.Backend.NCCL: + # This argument is needed to avoid warnings. + # It's valid only for NCCL backend. + tdist.barrier(device_ids=[torch.cuda.current_device()]) + else: + tdist.barrier() + + +@functools.lru_cache() +def _get_global_gloo_group(): + """ + Return a process group based on gloo backend, containing all the ranks + The result is cached. + """ + if tdist.get_backend() == "nccl": + return tdist.new_group(backend="gloo") + else: + return tdist.group.WORLD + + +def all_gather(data, group=None): + """ + Run all_gather on arbitrary picklable data (not necessarily tensors). + + Args: + data: any picklable object + group: a torch process group. By default, will use a group which + contains all ranks on gloo backend. + + Returns: + list[data]: list of data gathered from each rank + """ + if get_world_size() == 1: + return [data] + if group is None: + group = _get_global_gloo_group() # use CPU group by default, to reduce GPU RAM usage. + world_size = tdist.get_world_size(group) + if world_size == 1: + return [data] + + output = [None for _ in range(world_size)] + tdist.all_gather_object(output, data, group=group) + return output + + +def gather(data, dst=0, group=None): + """ + Run gather on arbitrary picklable data (not necessarily tensors). + + Args: + data: any picklable object + dst (int): destination rank + group: a torch process group. By default, will use a group which + contains all ranks on gloo backend. + + Returns: + list[data]: on dst, a list of data gathered from each rank. Otherwise, + an empty list. + """ + if get_world_size() == 1: + return [data] + if group is None: + group = _get_global_gloo_group() + world_size = tdist.get_world_size(group=group) + if world_size == 1: + return [data] + rank = tdist.get_rank(group=group) + + if rank == dst: + output = [None for _ in range(world_size)] + tdist.gather_object(data, output, dst=dst, group=group) + return output + else: + tdist.gather_object(data, None, dst=dst, group=group) + return [] + + +def shared_random_seed() -> int: + """ + Returns: + int: a random number that is the same across all workers. + If workers need a shared RNG, they can use this shared seed to + create one. + + All workers must call this function, otherwise it will deadlock. + """ + ints = np.random.randint(2**31) + all_ints: list[int] = all_gather(ints) # type: ignore + return all_ints[0] + + +def reduce_dict(input_dict, average=True): + """ + Reduce the values in the dictionary from all processes so that process with rank + 0 has the reduced results. + + Args: + input_dict (dict): inputs to be reduced. All the values must be scalar CUDA Tensor. + average (bool): whether to do average or sum + + Returns: + a dict with the same keys as input_dict, after reduction. + """ + world_size = get_world_size() + if world_size < 2: + return input_dict + with torch.no_grad(): + names = [] + values = [] + # sort the keys so that they are consistent across processes + for k in sorted(input_dict.keys()): + names.append(k) + values.append(input_dict[k]) + values = torch.stack(values, dim=0) + tdist.reduce(values, dst=0) + if tdist.get_rank() == 0 and average: + # only main process gets accumulated, so only divide by + # world_size in this case + values /= world_size + reduced_dict = {k: v for k, v in zip(names, values)} + return reduced_dict diff --git a/focoos/utils/distributed/dist.py b/focoos/utils/distributed/dist.py new file mode 100644 index 00000000..db09e853 --- /dev/null +++ b/focoos/utils/distributed/dist.py @@ -0,0 +1,157 @@ +# Copyright (c) Facebook, Inc. and its affiliates. +from datetime import timedelta +from typing import Optional + +import torch +import torch.distributed as tdist +from torch.multiprocessing.spawn import start_processes +from torch.nn.parallel import DistributedDataParallel + +from focoos.utils.logger import get_logger + +from . import comm + +logger = get_logger(__name__) +DEFAULT_TIMEOUT = timedelta(minutes=60) + + +def is_dist_available_and_initialized(): + if not tdist.is_available(): + return False + if not tdist.is_initialized(): + return False + return True + + +def _find_free_port(): + import socket + + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + # Binding to port 0 will cause the OS to find an available port for us + sock.bind(("", 0)) + port = sock.getsockname()[1] + sock.close() + # NOTE: there is still a chance the port could be taken by other processes. + return port + + +def launch( + main_func, + # Should be num_processes_per_machine, but kept for compatibility. + num_gpus_per_machine, + num_machines=1, + machine_rank=0, + dist_url: Optional[str] = None, + args=(), + timeout=DEFAULT_TIMEOUT, +): + """ + Launch multi-process or distributed training. + This function must be called on all machines involved in the training. + It will spawn child processes (defined by ``num_gpus_per_machine``) on each machine. + + Args: + main_func: a function that will be called by `main_func(*args)` + num_gpus_per_machine (int): number of processes per machine. When + using GPUs, this should be the number of GPUs. + num_machines (int): the total number of machines + machine_rank (int): the rank of this machine + dist_url (str): url to connect to for distributed jobs, including protocol + e.g. "tcp://127.0.0.1:8686". + Can be set to "auto" to automatically select a free port on localhost + timeout (timedelta): timeout of the distributed workers + args (tuple): arguments passed to main_func + """ + world_size = num_machines * num_gpus_per_machine + if world_size > 1: + # https://github.com/pytorch/pytorch/pull/14391 + # TODO prctl in spawned processes + + if dist_url == "auto": + assert num_machines == 1, "dist_url=auto not supported in multi-machine jobs." + port = _find_free_port() + dist_url = f"tcp://127.0.0.1:{port}" + if num_machines > 1 and dist_url and dist_url.startswith("file://"): + logger = get_logger(__name__) + logger.warning("file:// is not a reliable init_method in multi-machine jobs. Prefer tcp://") + + start_processes( + _distributed_worker, + nprocs=num_gpus_per_machine, + args=( + main_func, + world_size, + num_gpus_per_machine, + machine_rank, + dist_url, + args, + timeout, + ), + daemon=False, + ) + logger = get_logger(__name__) + logger.info(f"Distributed training finished with {world_size} processes.") + else: + main_func(*args) + + +def _distributed_worker( + local_rank, + main_func, + world_size, + num_gpus_per_machine, + machine_rank, + dist_url, + args, + timeout=DEFAULT_TIMEOUT, +): + has_gpu = torch.cuda.is_available() + if has_gpu: + assert num_gpus_per_machine <= torch.cuda.device_count() + global_rank = machine_rank * num_gpus_per_machine + local_rank + try: + tdist.init_process_group( + backend="NCCL" if has_gpu else "GLOO", + init_method=dist_url, + world_size=world_size, + rank=global_rank, + timeout=timeout, + ) + except Exception as e: + logger = get_logger(__name__) + logger.error("Process group URL: {}".format(dist_url)) + raise e + + # Setup the local process group. + comm.create_local_process_group(num_gpus_per_machine) + if has_gpu: + torch.cuda.set_device(local_rank) + + # synchronize is needed here to prevent a possible timeout after calling init_process_group + # See: https://github.com/facebookresearch/maskrcnn-benchmark/issues/172 + comm.synchronize() + + main_func(*args) + comm.synchronize() + + +def create_ddp_model(model, *, fp16_compression=False, **kwargs): + """ + Create a DistributedDataParallel model if there are >1 processes. + + Args: + model: a torch.nn.Module + fp16_compression: add fp16 compression hooks to the ddp object. + See more at https://pytorch.org/docs/stable/ddp_comm_hooks.html#torch.distributed.algorithms.ddp_comm_hooks.default_hooks.fp16_compress_hook + kwargs: other arguments of :module:`torch.nn.parallel.DistributedDataParallel`. + """ # noqa + if comm.get_world_size() == 1: + return model + if "device_ids" not in kwargs: + kwargs["device_ids"] = [comm.get_local_rank()] + ddp = DistributedDataParallel(model, **kwargs) + if fp16_compression: + from torch.distributed.algorithms.ddp_comm_hooks import default as comm_hooks + + ddp.register_comm_hook(state=None, hook=comm_hooks.fp16_compress_hook) + return ddp diff --git a/focoos/utils/env.py b/focoos/utils/env.py new file mode 100644 index 00000000..53073b81 --- /dev/null +++ b/focoos/utils/env.py @@ -0,0 +1,165 @@ +import importlib +import os +import random +import re +import subprocess +import sys +from collections import defaultdict +from datetime import datetime + +import numpy as np +import PIL +import torch +import torchvision +from tabulate import tabulate + +from focoos.utils.logger import get_logger + +TORCH_VERSION = tuple(int(x) for x in torch.__version__.split(".")[:2]) + + +def seed_all_rng(seed=None): + """ + Set the random seed for the RNG in torch, numpy and python. + + Args: + seed (int): if None, will use a strong random seed. + """ + if seed is None: + seed = os.getpid() + int(datetime.now().strftime("%S%f")) + int.from_bytes(os.urandom(2), "big") + logger = get_logger(__name__) + logger.info("Using a generated random seed {}".format(seed)) + np.random.seed(seed) + torch.manual_seed(seed) + random.seed(seed) + os.environ["PYTHONHASHSEED"] = str(seed) + + +def collect_torch_env(): + try: + import torch.__config__ + + return torch.__config__.show() + except ImportError: + # compatible with older versions of pytorch + from torch.utils.collect_env import get_pretty_env_info + + return get_pretty_env_info() + + +def detect_compute_compatibility(CUDA_HOME, so_file): + try: + cuobjdump = os.path.join(CUDA_HOME, "bin", "cuobjdump") + if os.path.isfile(cuobjdump): + output = subprocess.check_output("'{}' --list-elf '{}'".format(cuobjdump, so_file), shell=True) + output = output.decode("utf-8").strip().split("\n") + arch = [] + for line in output: + line = re.findall(r"\.sm_([0-9]*)\.", line)[0] + arch.append(".".join(line)) + arch = sorted(set(arch)) + return ", ".join(arch) + else: + return so_file + "; cannot find cuobjdump" + except Exception: + # unhandled failure + return so_file + + +def collect_env_info(): + has_gpu = torch.cuda.is_available() # true for both CUDA & ROCM + torch_version = torch.__version__ + + # NOTE that CUDA_HOME/ROCM_HOME could be None even when CUDA runtime libs are functional + from torch.utils.cpp_extension import CUDA_HOME, ROCM_HOME + + has_rocm = False + if (getattr(torch.version, "hip", None) is not None) and (ROCM_HOME is not None): + has_rocm = True + has_cuda = has_gpu and (not has_rocm) + + data = [] + data.append(("sys.platform", sys.platform)) # check-template.yml depends on it + data.append(("Python", sys.version.replace("\n", ""))) + data.append(("numpy", np.__version__)) + + data.append(("PyTorch", torch_version + " @" + os.path.dirname(torch.__file__))) + data.append(("PyTorch debug build", torch.version.debug)) + try: + data.append(("torch._C._GLIBCXX_USE_CXX11_ABI", torch._C._GLIBCXX_USE_CXX11_ABI)) + except Exception: + pass + + if not has_gpu: + has_gpu_text = "No: torch.cuda.is_available() == False" + else: + has_gpu_text = "Yes" + data.append(("GPU available", has_gpu_text)) + if has_gpu: + devices = defaultdict(list) + for k in range(torch.cuda.device_count()): + cap = ".".join(str(x) for x in torch.cuda.get_device_capability(k)) + name = torch.cuda.get_device_name(k) + f" (arch={cap})" + devices[name].append(str(k)) + for name, devids in devices.items(): + data.append(("GPU " + ",".join(devids), name)) + + if has_rocm: + msg = " - invalid!" if not (ROCM_HOME and os.path.isdir(ROCM_HOME)) else "" + data.append(("ROCM_HOME", str(ROCM_HOME) + msg)) + else: + try: + from torch.utils.collect_env import get_nvidia_driver_version + from torch.utils.collect_env import run as _run + + data.append(("Driver version", get_nvidia_driver_version(_run))) + except Exception: + pass + msg = " - invalid!" if not (CUDA_HOME and os.path.isdir(CUDA_HOME)) else "" + data.append(("CUDA_HOME", str(CUDA_HOME) + msg)) + + cuda_arch_list = os.environ.get("TORCH_CUDA_ARCH_LIST", None) + if cuda_arch_list: + data.append(("TORCH_CUDA_ARCH_LIST", cuda_arch_list)) + data.append(("Pillow", PIL.__version__)) + + try: + data.append( + ( + "torchvision", + str(torchvision.__version__) + " @" + os.path.dirname(torchvision.__file__), + ) + ) + if has_cuda: + try: + torchvision_C = importlib.util.find_spec("torchvision._C").origin + msg = detect_compute_compatibility(CUDA_HOME, torchvision_C) + data.append(("torchvision arch flags", msg)) + except (ImportError, AttributeError): + data.append(("torchvision._C", "Not found")) + except AttributeError: + data.append(("torchvision", "unknown")) + + try: + import fvcore + + data.append(("fvcore", fvcore.__version__)) + except (ImportError, AttributeError): + pass + + try: + import iopath + + data.append(("iopath", iopath.__version__)) + except (ImportError, AttributeError): + pass + + try: + import cv2 + + data.append(("cv2", cv2.__version__)) + except (ImportError, AttributeError): + data.append(("cv2", "Not found")) + env_str = tabulate(data) + "\n" + env_str += collect_torch_env() + return env_str diff --git a/focoos/utils/logger.py b/focoos/utils/logger.py index 8f9f39fe..477f797d 100644 --- a/focoos/utils/logger.py +++ b/focoos/utils/logger.py @@ -1,13 +1,41 @@ import logging import logging.config +import os +import sys +import time +from contextlib import contextmanager from functools import cache -from typing import Optional +from typing import Counter, Optional + +from tabulate import tabulate from focoos.config import FOCOOS_CONFIG, LogLevel +D2_LOG_BUFFER_SIZE_KEY: str = "D2_LOG_BUFFER_SIZE" + +DEFAULT_LOG_BUFFER_SIZE: int = 1024 * 1024 # 1MB + +LOG_FORMAT = "[%(asctime)s][%(levelname)s][%(name)s]: %(message)s" + +_LOG_COUNTER = Counter() +_LOG_TIMER = {} + class ColoredFormatter(logging.Formatter): - log_format = "[%(asctime)s][%(levelname)s][%(name)s]: %(message)s" + """ + A custom formatter that adds color to log messages based on their level. + + This formatter applies different colors to log messages depending on their severity level: + - DEBUG: yellow + - INFO: green + - WARNING: purple + - ERROR: bold red + - CRITICAL: bold red + + The format follows the standard LOG_FORMAT pattern with added ANSI color codes. + """ + + log_format = LOG_FORMAT grey = "\x1b[38;21m" green = "\x1b[1;32m" yellow = "\x1b[1;33m" @@ -20,13 +48,22 @@ class ColoredFormatter(logging.Formatter): FORMATS = { logging.DEBUG: yellow + log_format + reset, - logging.INFO: blue + log_format + reset, + logging.INFO: green + log_format + reset, logging.WARNING: purple + log_format + reset, logging.ERROR: bold_red + log_format + reset, logging.CRITICAL: bold_red + log_format + reset, } def format(self, record): + """ + Format the log record with appropriate colors. + + Args: + record: The log record to format + + Returns: + str: The formatted log message with color codes + """ log_fmt = self.FORMATS.get(record.levelno) formatter = logging.Formatter(log_fmt, datefmt="%m/%d %H:%M") return formatter.format(record) @@ -37,7 +74,7 @@ def format(self, record): "disable_existing_loggers": False, "formatters": { "color": { - # Assicurati di mettere il percorso completo al LogFormatter + # Make sure to use the full path to the LogFormatter "()": ColoredFormatter, # "use_colors": True, }, @@ -49,22 +86,48 @@ def format(self, record): "level": FOCOOS_CONFIG.focoos_log_level, }, }, - "root": { # Configura il logger di default (root) + "root": { # Configure the default (root) logger "handlers": ["default"], "level": "INFO", }, "loggers": { + # General configuration for all focoos.* loggers "focoos": { "handlers": ["default"], "level": FOCOOS_CONFIG.focoos_log_level, - "propagate": False, + "propagate": False, # Don't propagate to the root logger }, "matplotlib": {"level": "WARNING"}, "botocore": {"level": "INFO"}, + "fvcore": {"level": "DEBUG"}, + "onnxscript": {"level": "WARNING"}, }, } +def create_small_table(small_dict): + """ + Create a small table using the keys of small_dict as headers. This is only + suitable for small dictionaries. + + Args: + small_dict (dict): a result dictionary of only a few items. + + Returns: + str: the table as a string. + """ + keys, values = tuple(zip(*small_dict.items())) + table = tabulate( + [values], + headers=keys, + tablefmt="pipe", + floatfmt=".3f", + stralign="center", + numalign="center", + ) + return table + + @cache def get_logger(name="focoos", level: Optional[LogLevel] = None) -> logging.Logger: """ @@ -88,7 +151,230 @@ def get_logger(name="focoos", level: Optional[LogLevel] = None) -> logging.Logge return logger -def setup_logging(): +def _setup_logging(): + """ + Configure the logging system using the LOGGING_CONFIG dictionary. + + This function initializes the logging system with the predefined configuration, + setting up formatters, handlers, and logger levels. + """ logging.config.dictConfig(LOGGING_CONFIG) - logger = get_logger() - return logger + + +def _find_caller(): + """ + Find the calling module and location in the stack. + + This function walks up the call stack to find the first frame that is not + part of the logger module itself, to identify where the logging call originated. + + Returns: + str: module name of the caller + tuple: a hashable key to be used to identify different callers + """ + frame = sys._getframe(2) + while frame: + code = frame.f_code + if os.path.join("utils", "logger.") not in code.co_filename: + mod_name = frame.f_globals["__name__"] + if mod_name == "__main__": + mod_name = "focoos" + return mod_name, (code.co_filename, frame.f_lineno, code.co_name) + frame = frame.f_back + + +def log_first_n(lvl, msg, n=1, *, name=None, key="caller"): + """ + Log only for the first n times. + + Args: + lvl (int): the logging level + msg (str): + n (int): + name (str): name of the logger to use. Will use the caller's module by default. + key (str or tuple[str]): the string(s) can be one of "caller" or + "message", which defines how to identify duplicated logs. + For example, if called with `n=1, key="caller"`, this function + will only log the first call from the same caller, regardless of + the message content. + If called with `n=1, key="message"`, this function will log the + same content only once, even if they are called from different places. + If called with `n=1, key=("caller", "message")`, this function + will not log only if the same caller has logged the same message before. + """ + if isinstance(key, str): + key = (key,) + assert len(key) > 0 + + caller_module, caller_key = _find_caller() # type: ignore + hash_key = () + if "caller" in key: + hash_key = hash_key + caller_key + if "message" in key: + hash_key = hash_key + (msg,) + + _LOG_COUNTER[hash_key] += 1 + if _LOG_COUNTER[hash_key] <= n: + get_logger(name or caller_module).log(lvl, msg) + + +def log_every_n(lvl, msg, n=1, *, name=None): + """ + Log once per n times. + + Args: + lvl (int): the logging level + msg (str): + n (int): + name (str): name of the logger to use. Will use the caller's module by default. + """ + caller_module, key = _find_caller() # type: ignore + _LOG_COUNTER[key] += 1 + if n == 1 or _LOG_COUNTER[key] % n == 1: + get_logger(name or caller_module).log(lvl, msg) + + +def log_every_n_seconds(lvl, msg, n=1, *, name=None): + """ + Log no more than once per n seconds. + + Args: + lvl (int): the logging level + msg (str): + n (int): + name (str): name of the logger to use. Will use the caller's module by default. + """ + caller_module, key = _find_caller() # type: ignore + if key is None or caller_module is None: + return + last_logged = _LOG_TIMER.get(key, None) + current_time = time.time() + if last_logged is None or current_time - last_logged >= n: + get_logger(name or caller_module).log(lvl, msg) + _LOG_TIMER[key] = current_time + + +class TeeStream: + """ + A stream wrapper that duplicates output to both the original stream and a log file. + + This class is used to capture output that would normally go to stdout or stderr + and also write it to a log file, allowing for both console display and logging. + + Args: + original_stream: The original output stream (typically sys.stdout or sys.stderr) + log_file: The file object to which output should also be written + """ + + def __init__(self, original_stream, log_file): + self.original_stream = original_stream + self.log_file = log_file + + def write(self, data): + """ + Write data to both the original stream and the log file. + + Args: + data: The data to write + """ + self.original_stream.write(data) + self.log_file.write(data) + + def flush(self): + """Flush both the original stream and the log file.""" + self.original_stream.flush() + self.log_file.flush() + + +@contextmanager +def capture_all_output(log_path="output.txt", rank=0): + """ + Context manager that captures all stdout, stderr, and logging output to a file. + + This function redirects standard output streams and logging to a specified file, + which is useful for capturing all program output during execution. It's particularly + helpful in distributed environments where each process can have its own log file. + + Args: + log_path (str): Path to the log file or directory. If a directory is provided, + a file named "log.txt" will be created in that directory. + rank (int): Process rank in distributed training. Used to create rank-specific + log files when running with multiple processes. + + Yields: + None: This context manager doesn't yield a value, but sets up the logging + environment for the duration of the context. + + Example: + >>> with capture_all_output("logs/run1"): + >>> print("This will go to both console and log file") + >>> logger.info("So will this log message") + """ + # Handle the output path + if log_path.endswith(".txt") or log_path.endswith(".log"): + output = log_path + else: + output = os.path.join(log_path, "log.txt") + + # Modify the path based on rank + distributed_rank = rank + if distributed_rank > 0: + base, ext = os.path.splitext(output) + output = f"{base}.rank{distributed_rank}{ext}" + + # Create directory if needed + dirname = os.path.dirname(output) + if dirname != "": + os.makedirs(dirname, exist_ok=True) + + # Remove file if it already exists + if os.path.exists(output): + os.remove(output) + # Open file for stdout/stderr + log_file = open(output, "a", buffering=1, encoding="utf-8") # line-buffered + + # Create tee streams + tee_stdout = TeeStream(sys.stdout, log_file) + tee_stderr = TeeStream(sys.stderr, log_file) + + # Redirect sys + original_stdout = sys.stdout + original_stderr = sys.stderr + sys.stdout = tee_stdout + sys.stderr = tee_stderr + + # Redirect logging with FileHandler + logger_handler = logging.FileHandler(output) + # Set level based on rank + if distributed_rank > 0: + logger_handler.setLevel(logging.WARNING) + else: + logger_handler.setLevel(logging.DEBUG) + + logger_handler.setFormatter(logging.Formatter(LOG_FORMAT, datefmt="%m/%d %H:%M")) + + # Attach handler to both root logger and focoos logger + logging.getLogger().addHandler(logger_handler) + + # Add handler to focoos logger as well + focoos_logger = get_logger() + focoos_logger.addHandler(logger_handler) + + try: + yield # Enter block + finally: + # Restore + sys.stdout = original_stdout + sys.stderr = original_stderr + + # Close logger handler + logging.getLogger().removeHandler(logger_handler) + + # Remove handler from focoos logger + focoos_logger = get_logger() + focoos_logger.removeHandler(logger_handler) + + logger_handler.close() + + # Close file + log_file.close() diff --git a/focoos/utils/memory.py b/focoos/utils/memory.py new file mode 100644 index 00000000..964a9479 --- /dev/null +++ b/focoos/utils/memory.py @@ -0,0 +1,83 @@ +import logging +from contextlib import contextmanager +from functools import wraps + +import torch + +__all__ = ["retry_if_cuda_oom"] + + +@contextmanager +def _ignore_torch_cuda_oom(): + """ + A context which ignores CUDA OOM exception from pytorch. + """ + try: + yield + except RuntimeError as e: + # NOTE: the string may change? + if "CUDA out of memory. " in str(e): + pass + else: + raise + + +def retry_if_cuda_oom(func): + """ + Makes a function retry itself after encountering + pytorch's CUDA OOM error. + It will first retry after calling `torch.cuda.empty_cache()`. + + If that still fails, it will then retry by trying to convert inputs to CPUs. + In this case, it expects the function to dispatch to CPU implementation. + The return values may become CPU tensors as well and it's user's + responsibility to convert it back to CUDA tensor if needed. + + Args: + func: a stateless callable that takes tensor-like objects as arguments + + Returns: + a callable which retries `func` if OOM is encountered. + + Examples: + :: + output = retry_if_cuda_oom(some_torch_function)(input1, input2) + # output may be on CPU even if inputs are on GPU + + Note: + 1. When converting inputs to CPU, it will only look at each argument and check + if it has `.device` and `.to` for conversion. Nested structures of tensors + are not supported. + + 2. Since the function might be called more than once, it has to be + stateless. + """ + + def maybe_to_cpu(x): + try: + like_gpu_tensor = x.device.type == "cuda" and hasattr(x, "to") + except AttributeError: + like_gpu_tensor = False + if like_gpu_tensor: + return x.to(device="cpu") + else: + return x + + @wraps(func) + def wrapped(*args, **kwargs): + with _ignore_torch_cuda_oom(): + return func(*args, **kwargs) + + # Clear cache and retry + torch.cuda.empty_cache() + with _ignore_torch_cuda_oom(): + return func(*args, **kwargs) + + # Try on CPU. This slows down the code significantly, therefore print a notice. + logger = logging.getLogger(__name__) + logger.info("Attempting to copy inputs of {} to CPU due to CUDA OOM".format(str(func))) + new_args = (maybe_to_cpu(x) for x in args) + new_kwargs = {k: maybe_to_cpu(v) for k, v in kwargs.items()} + return func(*new_args, **new_kwargs) + + return wrapped diff --git a/focoos/utils/metrics.py b/focoos/utils/metrics.py index 7361039e..0739665a 100644 --- a/focoos/utils/metrics.py +++ b/focoos/utils/metrics.py @@ -1,7 +1,32 @@ +import orjson from colorama import Fore, Style from focoos.ports import Metrics +OBJECT_DETECTIONS_VALIDATION_METRICS = [ + "bbox/AP", + "bbox/AP50", + "bbox/AP75", + "bbox/APs", + "bbox/APm", + "bbox/APl", +] +SEGMENTATION_VALIDATION_METRICS = [ + "sem_seg/mIoU", + "sem_seg/fwIoU", + "sem_seg/pACC", + "sem_seg/mAcc", +] +INSTANCE_SEGMENTATION_VALIDATION_METRICS = [ + "segm/AP", + "segm/AP50", + "segm/AP75", + "segm/APs", + "segm/APm", + "segm/APl", +] +PANOPTIC_SEGMENTATION_VALIDATION_METRICS = ["panoptic_seg/PQ"] + class MetricsVisualizer: def __init__(self, metrics: Metrics): @@ -136,3 +161,84 @@ def plot_on_axis(ax, x_values, y_values_dict, xlabel, ylabel, title): plt.tight_layout() plt.show() + + +def parse_metrics(metrics_path: str) -> Metrics: + """ + Parse metrics from a file path and extract validation, training, and inference metrics. + + Args: + metrics_path (str): Path to a file containing JSON lines of metrics data. + + Returns: + Metrics: An instance of Metrics containing parsed metrics categorized into + 'valid_metrics', 'train_metrics', 'infer_metrics', along with 'iterations' + and 'best_valid_metric'. + """ + # Initialize the Metrics object with empty lists for metrics and None for iterations + res = Metrics( + valid_metrics=[], + train_metrics=[], + infer_metrics=[], + iterations=None, + best_valid_metric=None, + ) + + # Read the metrics file + with open(metrics_path, "r") as f: + metrics_text = f.read() + + # Parse the input metrics text into a list of dictionaries, ignoring empty lines + content = orjson.loads(metrics_text) + + # Create a set of all possible validation metrics for easy lookup + #! TODO: this is a hack to get the best metric to use delta, not stable for overlapping metrics + valid_metrics_set = set( + INSTANCE_SEGMENTATION_VALIDATION_METRICS + + OBJECT_DETECTIONS_VALIDATION_METRICS + + SEGMENTATION_VALIDATION_METRICS + + PANOPTIC_SEGMENTATION_VALIDATION_METRICS + ) + + # Iterate over each metric dictionary in the content + for metric in content: + # Filter out keys that end with a _digit, as they are not needed (loss_100, etc) + metric = { + k: round(v, 4) if isinstance(v, (int, float)) else v + for k, v in metric.items() + if not (k[-2] == "_" and k[-1].isdigit()) + } + # Determine if the metric belongs to validation or training based on the presence of validation metrics + if valid_metrics_set.intersection(metric): + res.valid_metrics.append(metric) + else: + res.train_metrics.append(metric) + + # If there are training metrics, set the total iterations to the last iteration value + if len(res.train_metrics) > 0: + res.iterations = res.train_metrics[-1].get("iteration", -1) + + # If there are validation metrics, update total iterations and find the best iteration + if len(res.valid_metrics) > 0: + # Update total iterations if the last validation iteration is greater + if res.valid_metrics[-1].get("iteration", -1) > (res.iterations or 0): + res.iterations = res.valid_metrics[-1].get("iteration") + + delta_keys = [ + INSTANCE_SEGMENTATION_VALIDATION_METRICS[0], + OBJECT_DETECTIONS_VALIDATION_METRICS[0], + SEGMENTATION_VALIDATION_METRICS[0], + PANOPTIC_SEGMENTATION_VALIDATION_METRICS[0], + ] + + # Determine the best metric to use for finding the best iteration + best_metric = None + for k in delta_keys: + if k in res.valid_metrics[0]: + best_metric = k + break + # If a best metric is found, determine the best iteration based on it + if best_metric: + res.best_valid_metric = max(res.valid_metrics, key=lambda x: x.get(best_metric, 0)) + + return res diff --git a/focoos/utils/system.py b/focoos/utils/system.py index d943d5cc..b8315b08 100644 --- a/focoos/utils/system.py +++ b/focoos/utils/system.py @@ -2,9 +2,15 @@ import os import platform import subprocess -from typing import Optional +import sys +import tarfile +import time +import zipfile +from pathlib import Path +from typing import List, Optional, Union from focoos.ports import GPUInfo +from focoos.utils.distributed import comm try: import onnxruntime as ort @@ -41,8 +47,9 @@ def get_cuda_version() -> Optional[str]: cuda_version = line.split(":")[-1].strip() cuda_version = cuda_version.split()[0] return cuda_version - except FileNotFoundError as err: - logger.warning("nvidia-smi command not found: %s", err) + except FileNotFoundError: + logger.warning("nvidia-smi not available") + return None def get_gpu_info() -> GPUInfo: @@ -120,7 +127,7 @@ def get_gpu_info() -> GPUInfo: gpu_info.gpu_count = len(gpus_device) gpu_info.gpu_driver = driver_version gpu_info.gpu_cuda_version = get_cuda_version() - + gpu_info.total_gpu_memory_gb = sum(device.gpu_memory_total_gb for device in gpus_device) except FileNotFoundError as err: logger.warning("nvidia-smi command not found: %s", err) except Exception as err: @@ -142,6 +149,10 @@ def get_cpu_name() -> Optional[str]: return platform.processor() +def get_focoos_version() -> str: + return metadata.version("focoos") + + def get_system_info() -> SystemInfo: """ Collect and return detailed system information. @@ -170,7 +181,6 @@ def get_system_info() -> SystemInfo: gpu_info = get_gpu_info() packages = [ - "focoos", "tensorrt", "onnxruntime", "onnxruntime-gpu", @@ -183,6 +193,7 @@ def get_system_info() -> SystemInfo: "torchvision", "nvidia-cuda-runtime-cu12", "tensorrt", + "fvcore", ] versions = {} for package in packages: @@ -190,26 +201,40 @@ def get_system_info() -> SystemInfo: versions[package] = metadata.version(package) except metadata.PackageNotFoundError: versions[package] = "unknown" - + focoos_version = get_focoos_version() environments_var = [ "LD_LIBRARY_PATH", "LD_PRELOAD", "CUDA_HOME", "CUDA_VISIBLE_DEVICES", "FOCOOS_LOG_LEVEL", - "DEFAULT_HOST_URL", ] environments = {} for var in environments_var: environments[var] = os.getenv(var, "") + try: + import torch + from torch.utils.cpp_extension import CUDA_HOME + + torch_cuda_home = CUDA_HOME + torch_cudnn_version = torch.backends.cudnn.version() + torch_info = f"{torch.__version__} cudnn: {torch_cudnn_version} cuda home: {torch_cuda_home} root: {os.path.dirname(torch.__file__)}" + except Exception as e: + logger.warning(f"Error getting torch cuda home: {e}") + torch_info = None + + ort_providers = ort.get_available_providers() if ort else None return SystemInfo( focoos_host=FOCOOS_CONFIG.default_host_url, + focoos_version=focoos_version, + python_version=sys.version.replace("\n", ""), system=system_info.system, system_name=system_info.node, + pytorch_info=torch_info, cpu_type=system_info.machine, cpu_cores=psutil.cpu_count(logical=True), - available_providers=ort.get_available_providers() if ort else None, + available_onnx_providers=ort_providers, memory_gb=round(memory_info.total / (1024**3), 3), memory_used_percentage=round(memory_info.percent, 3), disk_space_total_gb=round(disk_info.total / (1024**3), 3), @@ -218,3 +243,161 @@ def get_system_info() -> SystemInfo: packages_versions=versions, environment=environments, ) + + +def check_folder_exists(folder_path: Union[str, Path]) -> bool: + """ + Check if a specified folder exists. + + Parameters: + folder_path (Union[str, Path]): The path to the folder to check. + + Returns: + bool: True if the folder exists, False otherwise. + """ + folder_path = Path(folder_path) + return folder_path.is_dir() + + +def is_inside_sagemaker(): + res = os.environ.get("SM_HOSTS") is not None + return res + + +def list_dir(base_directory: Union[str, Path]) -> List[Path]: + """ + A function that lists directories within a base directory. + + Parameters: + - base_directory: A Union of str or Path, the base directory to list directories from. + + Returns: + - List[Path]: A list of Path objects representing directories within the base directory. + """ + base_directory = Path(base_directory) + directories = [child for child in base_directory.iterdir() if child.is_dir()] + return directories + + +def extract_archive( + archive_path: str, destination: Optional[str] = None, delete_original: bool = False +) -> Union[str, Path]: + """ + Extract an archive to a specified destination or the same folder. + + This function supports extracting .zip, .tar.gz, and .tar files. + + Args: + archive_path (str): The path to the archive file to be extracted. + destination (Optional[str]): The path where the archive should be extracted. + If None, the archive will be extracted to its current directory. + Defaults to None. + delete_original (bool): If True, deletes the original archive file after extraction. + Defaults to False. + + Returns: + str: The path to the directory where the archive was extracted. + + Raises: + ValueError: If the archive format is not supported. + + Note: + The function logs the start and end of the extraction process, including the time taken. + """ + + # Determine the extraction path + t0 = time.time() + base_dir = os.path.dirname(archive_path) + if destination is not None: + extracted_dir = os.path.join(base_dir, destination) + else: + extracted_dir = base_dir + + if comm.is_main_process(): + logger.info(f"Extracting archive: {archive_path} to {extracted_dir}") + + # Create the extracted directory + os.makedirs(extracted_dir, exist_ok=True) + + # Get the file extension + file_extension = get_file_extension(archive_path) + + # Extract the archive + if file_extension == "application/zip": + with zipfile.ZipFile(archive_path, "r") as zip_ref: + zip_ref.extractall(extracted_dir) + elif file_extension == "application/gzip": + with tarfile.open(archive_path, "r:gz") as tar_ref: + tar_ref.extractall(extracted_dir) + elif file_extension == "application/x-tar": + with tarfile.open(archive_path, "r:") as tar_ref: + tar_ref.extractall(extracted_dir) + else: + raise ValueError("Unsupported archive format. Only .zip and .tar.gz are supported.") + t1 = time.time() + logger.info(f"[elapsed {t1 - t0:.3f} ] Extracted archive to: {extracted_dir}") + + comm.synchronize() + if len(list_dir(extracted_dir)) == 1: + extracted_dir = list_dir(extracted_dir)[0] + + # Optionally delete the original archive + if delete_original: + os.remove(archive_path) + + return extracted_dir + + +def get_file_extension(file_path): + """ + Determine the MIME type of a file based on its extension. + + Args: + file_path (str): Path to the file + + Returns: + str: MIME type of the file + """ + extension = os.path.splitext(file_path)[1].lower() + + # Map common extensions to MIME types + mime_types = { + ".zip": "application/zip", + ".gz": "application/gzip", + ".tar": "application/x-tar", + ".tar.gz": "application/gzip", + ".tgz": "application/gzip", + } + + # Check for .tar.gz extension first + if file_path.lower().endswith(".tar.gz") or file_path.lower().endswith(".tgz"): + mime_type = "application/gzip" + else: + mime_type = mime_types.get(extension, "application/octet-stream") + + logger.debug(f"Supposed file extension: {mime_type}") + return mime_type + + +def list_files_with_extensions(base_dir: Union[str, Path], extensions: Optional[List[str]] = None) -> List[Path]: + """ + A function that lists files in a directory based on the provided extensions. + + Parameters: + - base_dir: Union[str, Path] - The base directory where the files will be listed. + - extensions: Optional[List[str]] - A list of file extensions to filter the files by. + + Returns: + - List[Path]: A list of Path objects representing the files in the directory matching the provided extensions. + """ + base_dir = Path(base_dir) + if extensions: + files = [] + for ext in extensions: + if ext.startswith("."): + ext = ext[1:] + _glob = f"*.{ext}" + files.extend(base_dir.glob(_glob)) + else: + files = base_dir.glob("*") + return [file for file in files if file.is_file()] diff --git a/focoos/utils/timer.py b/focoos/utils/timer.py new file mode 100644 index 00000000..3de4a16e --- /dev/null +++ b/focoos/utils/timer.py @@ -0,0 +1,70 @@ +# Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved. +# -*- coding: utf-8 -*- + +from time import perf_counter +from typing import Optional + + +class Timer: + """ + A timer which computes the time elapsed since the start/reset of the timer. + """ + + def __init__(self) -> None: + self.reset() + + def reset(self) -> None: + """ + Reset the timer. + """ + self._start = perf_counter() + self._paused: Optional[float] = None + self._total_paused = 0 + self._count_start = 1 + + def pause(self) -> None: + """ + Pause the timer. + """ + if self._paused is not None: + raise ValueError("Trying to pause a Timer that is already paused!") + self._paused = perf_counter() + + def is_paused(self) -> bool: + """ + Returns: + bool: whether the timer is currently paused + """ + return self._paused is not None + + def resume(self) -> None: + """ + Resume the timer. + """ + if self._paused is None: + raise ValueError("Trying to resume a Timer that is not paused!") + # pyre-fixme[58]: `-` is not supported for operand types `float` and + # `Optional[float]`. + self._total_paused += perf_counter() - self._paused + self._paused = None + self._count_start += 1 + + def seconds(self) -> float: + """ + Returns: + (float): the total number of seconds since the start/reset of the + timer, excluding the time when the timer is paused. + """ + if self._paused is not None: + end_time: float = self._paused # type: ignore + else: + end_time = perf_counter() + return end_time - self._start - self._total_paused + + def avg_seconds(self) -> float: + """ + Returns: + (float): the average number of seconds between every start/reset and + pause. + """ + return self.seconds() / self._count_start diff --git a/focoos/utils/vision.py b/focoos/utils/vision.py index 2e707e4e..a564a5d5 100644 --- a/focoos/utils/vision.py +++ b/focoos/utils/vision.py @@ -9,7 +9,7 @@ from scipy.ndimage import zoom from typing_extensions import Buffer -from focoos.ports import FocoosDet, FocoosDetections, FocoosTask +from focoos.ports import FocoosDet, FocoosDetections, Task def index_to_class(class_ids: list[int], classes: list[str]) -> list[str]: @@ -148,6 +148,7 @@ def fai_detections_to_sv(inference_output: FocoosDetections, im0_shape: tuple) - confidence = np.array([d.conf for d in inference_output.detections]) if xyxy.shape[0] == 0: xyxy = np.zeros((0, 4)) + _masks = [] if len(inference_output.detections) > 0 and inference_output.detections[0].mask: _masks = [np.zeros(im0_shape, dtype=bool) for _ in inference_output.detections] @@ -156,7 +157,7 @@ def fai_detections_to_sv(inference_output: FocoosDetections, im0_shape: tuple) - mask = base64mask_to_mask(det.mask) if det.bbox is not None and not np.array_equal(det.bbox, [0, 0, 0, 0]): x1, y1, x2, y2 = map(int, det.bbox) - y2, x2 = min(y2, _masks[i].shape[0]), min(x2, _masks[i].shape[1]) + y2, x2 = min(y2, _masks[i].shape[0]), min(x2, _masks[i].shape[1]) # type: ignore _masks[i][y1:y2, x1:x2] = mask[: y2 - y1, : x2 - x1] else: _masks[i] = mask @@ -169,6 +170,12 @@ def fai_detections_to_sv(inference_output: FocoosDetections, im0_shape: tuple) - ) +def trim_mask(mask: np.ndarray, bbox: np.ndarray) -> np.ndarray: + x1, y1, x2, y2 = map(int, bbox) + y2, x2 = min(y2, mask.shape[0]), min(x2, mask.shape[1]) # type: ignore + return mask[y1:y2, x1:x2] + + def binary_mask_to_base64(binary_mask: np.ndarray) -> str: """ Converts a binary mask (NumPy array) to a base64-encoded PNG image using OpenCV. @@ -232,7 +239,6 @@ def sv_to_fai_detections(detections: sv.Detections, classes: Optional[list[str]] y2 = min(y2 + 2, mask.shape[0]) cropped_mask = mask[y1:y2, x1:x2] mask = binary_mask_to_base64(cropped_mask) - det = FocoosDet( cls_id=int(cls_id) if cls_id is not None else None, bbox=[int(x) for x in xyxy], @@ -273,10 +279,10 @@ def masks_to_xyxy(masks: np.ndarray) -> np.ndarray: return xyxy -def get_postprocess_fn(task: FocoosTask): - if task == FocoosTask.INSTANCE_SEGMENTATION: +def get_postprocess_fn(task: Task): + if task == Task.INSTANCE_SEGMENTATION: return instance_postprocess - elif task == FocoosTask.SEMSEG: + elif task == Task.SEMSEG: return semseg_postprocess else: return det_postprocess @@ -380,3 +386,71 @@ def instance_postprocess(out: List[np.ndarray], im0_shape: Tuple[int, int], conf class_id=cls_ids, confidence=confs, ) + + +def annotate_image( + im: Union[np.ndarray, Image.Image], detections: FocoosDetections, task: Task, classes: Optional[list[str]] = None +) -> Image.Image: + if isinstance(im, Image.Image): + im = np.array(im) + label_annotator = sv.LabelAnnotator(text_padding=10, border_radius=10) + box_annotator = sv.BoxAnnotator() + mask_annotator = sv.MaskAnnotator() + + sv_detections = fai_detections_to_sv(detections, im.shape[:2]) + if len(sv_detections.xyxy) == 0: + print("No detections found, skipping annotation") + return Image.fromarray(im) + + if task == Task.DETECTION: + annotated_im = box_annotator.annotate(scene=im.copy(), detections=sv_detections) + + elif task in [ + Task.SEMSEG, + Task.INSTANCE_SEGMENTATION, + ]: + annotated_im = mask_annotator.annotate(scene=im.copy(), detections=sv_detections) + + # Fixme: get the classes from the detections + if classes is not None: + labels = [ + f"{classes[int(class_id)] if classes is not None else str(class_id)}: {confid * 100:.0f}%" + for class_id, confid in zip(sv_detections.class_id, sv_detections.confidence) # type: ignore + ] + annotated_im = label_annotator.annotate(scene=annotated_im, detections=sv_detections, labels=labels) + + return Image.fromarray(annotated_im) + + +def annotate_frame( + im: np.ndarray, detections: FocoosDetections, task: Task, classes: Optional[list[str]] = None +) -> np.ndarray: + if isinstance(im, Image.Image): + im = np.array(im) + label_annotator = sv.LabelAnnotator(text_padding=10, border_radius=10) + box_annotator = sv.BoxAnnotator() + mask_annotator = sv.MaskAnnotator() + + sv_detections = fai_detections_to_sv(detections, im.shape[:2]) + if len(sv_detections.xyxy) == 0: + print("No detections found, skipping annotation") + return im + + if task == Task.DETECTION: + annotated_im = box_annotator.annotate(scene=im.copy(), detections=sv_detections) + + elif task in [ + Task.SEMSEG, + Task.INSTANCE_SEGMENTATION, + ]: + annotated_im = mask_annotator.annotate(scene=im.copy(), detections=sv_detections) + + # Fixme: get the classes from the detections + if classes is not None: + labels = [ + f"{classes[int(class_id)] if classes is not None else str(class_id)}: {confid * 100:.0f}%" + for class_id, confid in zip(sv_detections.class_id, sv_detections.confidence) # type: ignore + ] + annotated_im = label_annotator.annotate(scene=annotated_im, detections=sv_detections, labels=labels) + + return annotated_im diff --git a/focoos/utils/visualizer.py b/focoos/utils/visualizer.py new file mode 100644 index 00000000..5fd7ad57 --- /dev/null +++ b/focoos/utils/visualizer.py @@ -0,0 +1,1495 @@ +# Copyright (c) Facebook, Inc. and its affiliates. +# Imported and Adapted from Detectron2 +import colorsys +import math +from enum import Enum, unique + +import cv2 +import matplotlib as mpl +import matplotlib.colors as mplc +import matplotlib.figure as mplfigure +import numpy as np +import pycocotools.mask as mask_util +import torch +from matplotlib.backends.backend_agg import FigureCanvasAgg +from PIL import Image + +from focoos.structures import ( + BitMasks, + Boxes, + BoxMode, + Keypoints, +) +from focoos.utils.logger import get_logger + +logger = get_logger(__name__) + +__all__ = ["ColorMode", "VisImage", "Visualizer"] + +_SMALL_OBJECT_AREA_THRESH = 1000 +_LARGE_MASK_AREA_THRESH = 120000 +_OFF_WHITE = (1.0, 1.0, 240.0 / 255) +_BLACK = (0, 0, 0) +_RED = (1.0, 0, 0) + +_KEYPOINT_THRESHOLD = 0.05 + + +_COLORS = ( + np.array( + [ + 0.000, + 0.447, + 0.741, + 0.850, + 0.325, + 0.098, + 0.929, + 0.694, + 0.125, + 0.494, + 0.184, + 0.556, + 0.466, + 0.674, + 0.188, + 0.301, + 0.745, + 0.933, + 0.635, + 0.078, + 0.184, + 0.300, + 0.300, + 0.300, + 0.600, + 0.600, + 0.600, + 1.000, + 0.000, + 0.000, + 1.000, + 0.500, + 0.000, + 0.749, + 0.749, + 0.000, + 0.000, + 1.000, + 0.000, + 0.000, + 0.000, + 1.000, + 0.667, + 0.000, + 1.000, + 0.333, + 0.333, + 0.000, + 0.333, + 0.667, + 0.000, + 0.333, + 1.000, + 0.000, + 0.667, + 0.333, + 0.000, + 0.667, + 0.667, + 0.000, + 0.667, + 1.000, + 0.000, + 1.000, + 0.333, + 0.000, + 1.000, + 0.667, + 0.000, + 1.000, + 1.000, + 0.000, + 0.000, + 0.333, + 0.500, + 0.000, + 0.667, + 0.500, + 0.000, + 1.000, + 0.500, + 0.333, + 0.000, + 0.500, + 0.333, + 0.333, + 0.500, + 0.333, + 0.667, + 0.500, + 0.333, + 1.000, + 0.500, + 0.667, + 0.000, + 0.500, + 0.667, + 0.333, + 0.500, + 0.667, + 0.667, + 0.500, + 0.667, + 1.000, + 0.500, + 1.000, + 0.000, + 0.500, + 1.000, + 0.333, + 0.500, + 1.000, + 0.667, + 0.500, + 1.000, + 1.000, + 0.500, + 0.000, + 0.333, + 1.000, + 0.000, + 0.667, + 1.000, + 0.000, + 1.000, + 1.000, + 0.333, + 0.000, + 1.000, + 0.333, + 0.333, + 1.000, + 0.333, + 0.667, + 1.000, + 0.333, + 1.000, + 1.000, + 0.667, + 0.000, + 1.000, + 0.667, + 0.333, + 1.000, + 0.667, + 0.667, + 1.000, + 0.667, + 1.000, + 1.000, + 1.000, + 0.000, + 1.000, + 1.000, + 0.333, + 1.000, + 1.000, + 0.667, + 1.000, + 0.333, + 0.000, + 0.000, + 0.500, + 0.000, + 0.000, + 0.667, + 0.000, + 0.000, + 0.833, + 0.000, + 0.000, + 1.000, + 0.000, + 0.000, + 0.000, + 0.167, + 0.000, + 0.000, + 0.333, + 0.000, + 0.000, + 0.500, + 0.000, + 0.000, + 0.667, + 0.000, + 0.000, + 0.833, + 0.000, + 0.000, + 1.000, + 0.000, + 0.000, + 0.000, + 0.167, + 0.000, + 0.000, + 0.333, + 0.000, + 0.000, + 0.500, + 0.000, + 0.000, + 0.667, + 0.000, + 0.000, + 0.833, + 0.000, + 0.000, + 1.000, + 0.000, + 0.000, + 0.000, + 0.143, + 0.143, + 0.143, + 0.857, + 0.857, + 0.857, + 1.000, + 1.000, + 1.000, + ] + ) + .astype(np.float32) + .reshape(-1, 3) +) + + +def random_color(rgb=False, maximum=255): + """ + Args: + rgb (bool): whether to return RGB colors or BGR colors. + maximum (int): either 255 or 1 + + Returns: + ndarray: a vector of 3 numbers + """ + idx = np.random.randint(0, len(_COLORS)) + ret = _COLORS[idx] * maximum + if not rgb: + ret = ret[::-1] + return ret + + +@unique +class ColorMode(Enum): + """ + Enum of different color modes to use for instance visualizations. + """ + + IMAGE = 0 + """ + Picks a random color for every instance and overlay segmentations with low opacity. + """ + SEGMENTATION = 1 + """ + Let instances of the same category have similar colors + (from metadata.thing_colors), and overlay them with + high opacity. This provides more attention on the quality of segmentation. + """ + IMAGE_BW = 2 + """ + Same as IMAGE, but convert all areas without masks to gray-scale. + Only available for drawing per-instance mask predictions. + """ + + +class GenericMask: + """ + Attribute: + polygons (list[ndarray]): list[ndarray]: polygons for this mask. + Each ndarray has format [x, y, x, y, ...] + mask (ndarray): a binary mask + """ + + def __init__(self, mask_or_polygons, height, width): + self._mask = self._polygons = self._has_holes = None + self.height = height + self.width = width + + m = mask_or_polygons + if isinstance(m, dict): + # RLEs + assert "counts" in m and "size" in m + if isinstance(m["counts"], list): # uncompressed RLEs + h, w = m["size"] + assert h == height and w == width + m = mask_util.frPyObjects(m, h, w) + self._mask = mask_util.decode(m)[:, :] + return + + if isinstance(m, list): # list[ndarray] + self._polygons = [np.asarray(x).reshape(-1) for x in m] + return + + if isinstance(m, np.ndarray): # assumed to be a binary mask + assert m.shape[1] != 2, m.shape + assert m.shape == ( + height, + width, + ), f"mask shape: {m.shape}, target dims: {height}, {width}" + self._mask = m.astype("uint8") + return + + raise ValueError("GenericMask cannot handle object {} of type '{}'".format(m, type(m))) + + @property + def mask(self): + if self._mask is None: + self._mask = self.polygons_to_mask(self._polygons) + return self._mask + + @property + def polygons(self): + if self._polygons is None: + self._polygons, self._has_holes = self.mask_to_polygons(self._mask) + return self._polygons + + @property + def has_holes(self): + if self._has_holes is None: + if self._mask is not None: + self._polygons, self._has_holes = self.mask_to_polygons(self._mask) + else: + self._has_holes = False # if original format is polygon, does not have holes + return self._has_holes + + def mask_to_polygons(self, mask): + # cv2.RETR_CCOMP flag retrieves all the contours and arranges them to a 2-level + # hierarchy. External contours (boundary) of the object are placed in hierarchy-1. + # Internal contours (holes) are placed in hierarchy-2. + # cv2.CHAIN_APPROX_NONE flag gets vertices of polygons from contours. + mask = np.ascontiguousarray(mask) # some versions of cv2 does not support incontiguous arr + res = cv2.findContours(mask.astype("uint8"), cv2.RETR_CCOMP, cv2.CHAIN_APPROX_NONE) + hierarchy = res[-1] + if hierarchy is None: # empty mask + return [], False + has_holes = (hierarchy.reshape(-1, 4)[:, 3] >= 0).sum() > 0 + res = res[-2] + res = [x.flatten() for x in res] + # These coordinates from OpenCV are integers in range [0, W-1 or H-1]. + # We add 0.5 to turn them into real-value coordinate space. A better solution + # would be to first +0.5 and then dilate the returned polygon by 0.5. + res = [x + 0.5 for x in res if len(x) >= 6] + return res, has_holes + + def polygons_to_mask(self, polygons): + rle = mask_util.frPyObjects(polygons, self.height, self.width) + rle = mask_util.merge(rle) + return mask_util.decode(rle)[:, :] + + def area(self): + return self.mask.sum() + + def bbox(self): + p = mask_util.frPyObjects(self.polygons, self.height, self.width) + p = mask_util.merge(p) + bbox = mask_util.toBbox(p) + bbox[2] += bbox[0] + bbox[3] += bbox[1] + return bbox + + +class _PanopticPrediction: + """ + Unify different panoptic annotation/prediction formats + """ + + def __init__(self, panoptic_seg, segments_info, metadata=None): + if segments_info is None: + assert metadata is not None + # If "segments_info" is None, we assume "panoptic_img" is a + # H*W int32 image storing the panoptic_id in the format of + # category_id * label_divisor + instance_id. We reserve -1 for + # VOID label. + label_divisor = metadata.label_divisor + segments_info = [] + for panoptic_label in np.unique(panoptic_seg.numpy()): + if panoptic_label == -1: + # VOID region. + continue + pred_class = panoptic_label // label_divisor + isthing = pred_class in metadata.thing_dataset_id_to_contiguous_id.values() + segments_info.append( + { + "id": int(panoptic_label), + "category_id": int(pred_class), + "isthing": bool(isthing), + } + ) + del metadata + + self._seg = panoptic_seg + + self._sinfo = {s["id"]: s for s in segments_info} # seg id -> seg info + segment_ids, areas = torch.unique(panoptic_seg, sorted=True, return_counts=True) + areas = areas.numpy() + sorted_idxs = np.argsort(-areas) + self._seg_ids, self._seg_areas = segment_ids[sorted_idxs], areas[sorted_idxs] + self._seg_ids = self._seg_ids.tolist() + for sid, area in zip(self._seg_ids, self._seg_areas): + if sid in self._sinfo: + self._sinfo[sid]["area"] = float(area) + + def non_empty_mask(self): + """ + Returns: + (H, W) array, a mask for all pixels that have a prediction + """ + empty_ids = [] + for id in self._seg_ids: + if id not in self._sinfo: + empty_ids.append(id) + if len(empty_ids) == 0: + return np.zeros(self._seg.shape, dtype=np.uint8) + assert len(empty_ids) == 1, ">1 ids corresponds to no labels. This is currently not supported" + return (self._seg != empty_ids[0]).numpy().astype(bool) + + def semantic_masks(self): + for sid in self._seg_ids: + sinfo = self._sinfo.get(sid) + if sinfo is None or sinfo["isthing"]: + # Some pixels (e.g. id 0 in PanopticFPN) have no instance or semantic predictions. + continue + yield (self._seg == sid).numpy().astype(bool), sinfo + + def instance_masks(self): + for sid in self._seg_ids: + sinfo = self._sinfo.get(sid) + if sinfo is None or not sinfo["isthing"]: + continue + mask = (self._seg == sid).numpy().astype(bool) + if mask.sum() > 0: + yield mask, sinfo + + +def _create_text_labels(classes, scores, class_names, is_crowd=None): + """ + Args: + classes (list[int] or None): + scores (list[float] or None): + class_names (list[str] or None): + is_crowd (list[bool] or None): + + Returns: + list[str] or None + """ + labels = None + if classes is not None: + if class_names is not None and len(class_names) > 0: + labels = [class_names[i] for i in classes] + else: + labels = [str(i) for i in classes] + if scores is not None: + if labels is None: + labels = ["{:.0f}%".format(s * 100) for s in scores] + else: + labels = ["{} {:.0f}%".format(label, s * 100) for label, s in zip(labels, scores)] + if labels is not None and is_crowd is not None: + labels = [label + ("|crowd" if crowd else "") for label, crowd in zip(labels, is_crowd)] + return labels + + +class VisImage: + def __init__(self, img, scale=1.0): + """ + Args: + img (ndarray): an RGB image of shape (H, W, 3) in range [0, 255]. + scale (float): scale the input image + """ + self.img = img + self.scale = scale + self.width, self.height = img.shape[1], img.shape[0] + self._setup_figure(img) + + def _setup_figure(self, img): + """ + Args: + Same as in :meth:`__init__()`. + + Returns: + fig (matplotlib.pyplot.figure): top level container for all the image plot elements. + ax (matplotlib.pyplot.Axes): contains figure elements and sets the coordinate system. + """ + fig = mplfigure.Figure(frameon=False) + self.dpi = fig.get_dpi() + # add a small 1e-2 to avoid precision lost due to matplotlib's truncation + # (https://github.com/matplotlib/matplotlib/issues/15363) + fig.set_size_inches( + (self.width * self.scale + 1e-2) / self.dpi, + (self.height * self.scale + 1e-2) / self.dpi, + ) + self.canvas = FigureCanvasAgg(fig) + # self.canvas = mpl.backends.backend_cairo.FigureCanvasCairo(fig) + ax = fig.add_axes([0.0, 0.0, 1.0, 1.0]) + ax.axis("off") + self.fig = fig + self.ax = ax + self.reset_image(img) + + def reset_image(self, img): + """ + Args: + img: same as in __init__ + """ + img = img.astype("uint8") + self.ax.imshow(img, extent=(0, self.width, self.height, 0), interpolation="nearest") + + def save(self, filepath): + """ + Args: + filepath (str): a string that contains the absolute path, including the file name, where + the visualized image will be saved. + """ + self.fig.savefig(filepath) + + def get_image(self): + """ + Returns: + ndarray: + the visualized image of shape (H, W, 3) (RGB) in uint8 type. + The shape is scaled w.r.t the input image using the given `scale` argument. + """ + canvas = self.canvas + s, (width, height) = canvas.print_to_buffer() + # buf = io.BytesIO() # works for cairo backend + # canvas.print_rgba(buf) + # width, height = self.width, self.height + # s = buf.getvalue() + + buffer = np.frombuffer(s, dtype="uint8") + + img_rgba = buffer.reshape(height, width, 4) + rgb, alpha = np.split(img_rgba, [3], axis=2) + return rgb.astype("uint8") + + +class Visualizer: + """ + Visualizer that draws data about detection/segmentation on images. + + It contains methods like `draw_{text,box,circle,line,binary_mask,polygon}` + that draw primitive objects to images, as well as high-level wrappers like + `draw_{instance_predictions,sem_seg,panoptic_seg_predictions,dataset_dict}` + that draw composite data in some pre-defined style. + + Note that the exact visualization style for the high-level wrappers are subject to change. + Style such as color, opacity, label contents, visibility of labels, or even the visibility + of objects themselves (e.g. when the object is too small) may change according + to different heuristics, as long as the results still look visually reasonable. + + To obtain a consistent style, you can implement custom drawing functions with the + abovementioned primitive methods instead. If you need more customized visualization + styles, you can process the data yourself following their format documented in + tutorials (:doc:`/tutorials/models`, :doc:`/tutorials/datasets`). This class does not + intend to satisfy everyone's preference on drawing styles. + + This visualizer focuses on high rendering quality rather than performance. It is not + designed to be used for real-time applications. + """ + + # TODO implement a fast, rasterized version using OpenCV + + def __init__(self, img_rgb, metadata, scale=1.0, instance_mode=ColorMode.IMAGE): + """ + Args: + img_rgb: a numpy array of shape (H, W, C), where H and W correspond to + the height and width of the image respectively. C is the number of + color channels. The image is required to be in RGB format since that + is a requirement of the Matplotlib library. The image is also expected + to be in the range [0, 255]. + metadata (Metadata): dataset metadata (e.g. class names and colors) + instance_mode (ColorMode): defines one of the pre-defined style for drawing + instances on an image. + """ + self.img = np.asarray(img_rgb).clip(0, 255).astype(np.uint8) + self.metadata = metadata + self.output = VisImage(self.img, scale=scale) + self.cpu_device = torch.device("cpu") + + # too small texts are useless, therefore clamp to 9 + self._default_font_size = max(np.sqrt(self.output.height * self.output.width) // 90, 10 // scale) + self._instance_mode = instance_mode + self.keypoint_threshold = _KEYPOINT_THRESHOLD + + def draw_instance_predictions(self, predictions): + """ + Draw instance-level prediction results on an image. + + Args: + predictions (Instances): the output of an instance detection/segmentation + model. Following fields will be used to draw: + "pred_boxes", "pred_classes", "scores", "pred_masks" (or "pred_masks_rle"). + + Returns: + output (VisImage): image object with visualizations. + """ + boxes = predictions.boxes + scores = predictions.scores + classes = predictions.classes.tolist() + labels = _create_text_labels(classes, scores, self.metadata.get("thing_classes", None)) + keypoints = predictions.keypoints + + if predictions.masks is not None: + masks = np.asarray(predictions.masks) + masks = [GenericMask(x, self.output.height, self.output.width) for x in masks] + else: + masks = None + + if self._instance_mode == ColorMode.SEGMENTATION and self.metadata.get("thing_colors"): + colors = [self._jitter([x / 255 for x in self.metadata.thing_colors[c]]) for c in classes] + alpha = 0.8 + else: + colors = None + alpha = 0.5 + + if self._instance_mode == ColorMode.IMAGE_BW: + self.output.reset_image( + self._create_grayscale_image( + (predictions.masks.any(dim=0) > 0).numpy() if predictions.masks is not None else None + ) + ) + alpha = 0.3 + + self.overlay_instances( + masks=masks, + boxes=boxes, + labels=labels, + keypoints=keypoints, + assigned_colors=colors, + alpha=alpha, + ) + return self.output + + def draw_sem_seg(self, sem_seg, area_threshold=None, alpha=0.8): + """ + Draw semantic segmentation predictions/labels. + + Args: + sem_seg (Tensor or ndarray): the segmentation of shape (H, W). + Each value is the integer label of the pixel. + area_threshold (int): segments with less than `area_threshold` are not drawn. + alpha (float): the larger it is, the more opaque the segmentations are. + + Returns: + output (VisImage): image object with visualizations. + """ + if isinstance(sem_seg, torch.Tensor): + sem_seg = sem_seg.numpy() + labels, areas = np.unique(sem_seg, return_counts=True) + sorted_idxs = np.argsort(-areas).tolist() + labels = labels[sorted_idxs] + for label in filter(lambda label: label < len(self.metadata.stuff_classes), labels): + try: + mask_color = [x / 255 for x in self.metadata.stuff_colors[label]] + except (AttributeError, IndexError): + mask_color = None + + binary_mask = (sem_seg == label).astype(np.uint8) + text = self.metadata.stuff_classes[label] + self.draw_binary_mask( + binary_mask, + color=mask_color, + edge_color=_OFF_WHITE, + text=text, + alpha=alpha, + area_threshold=area_threshold, + ) + return self.output + + def draw_panoptic_seg(self, panoptic_seg, segments_info, area_threshold=None, alpha=0.7): + """ + Draw panoptic prediction annotations or results. + + Args: + panoptic_seg (Tensor): of shape (height, width) where the values are ids for each + segment. + segments_info (list[dict] or None): Describe each segment in `panoptic_seg`. + If it is a ``list[dict]``, each dict contains keys "id", "category_id". + If None, category id of each pixel is computed by + ``pixel // metadata.label_divisor``. + area_threshold (int): stuff segments with less than `area_threshold` are not drawn. + + Returns: + output (VisImage): image object with visualizations. + """ + pred = _PanopticPrediction(panoptic_seg, segments_info, self.metadata) + + if self._instance_mode == ColorMode.IMAGE_BW: + self.output.reset_image(self._create_grayscale_image(pred.non_empty_mask())) + + # draw mask for all semantic segments first i.e. "stuff" + for mask, sinfo in pred.semantic_masks(): + category_idx = sinfo["category_id"] + try: + mask_color = [x / 255 for x in self.metadata.stuff_colors[category_idx]] + except AttributeError: + mask_color = None + + text = self.metadata.stuff_classes[category_idx] + self.draw_binary_mask( + mask, + color=mask_color, + edge_color=_OFF_WHITE, + text=text, + alpha=alpha, + area_threshold=area_threshold, + ) + + # draw mask for all instances second + all_instances = list(pred.instance_masks()) + if len(all_instances) == 0: + return self.output + masks, sinfo = list(zip(*all_instances)) + category_ids = [x["category_id"] for x in sinfo] + + try: + scores = [x["score"] for x in sinfo] + except KeyError: + scores = None + labels = _create_text_labels( + category_ids, + scores, + self.metadata.classes, + [x.get("iscrowd", 0) for x in sinfo], + ) + + try: + colors = [self._jitter([x / 255 for x in self.metadata.thing_colors[c]]) for c in category_ids] + except AttributeError: + colors = None + self.overlay_instances(masks=masks, labels=labels, assigned_colors=colors, alpha=alpha) + + return self.output + + draw_panoptic_seg_predictions = draw_panoptic_seg # backward compatibility + + def draw_dataset_dict(self, dic): + """ + Draw annotations/segmentations in Detectron2 Dataset format. + + Args: + dic (dict): annotation/segmentation data of one image, in Detectron2 Dataset format. + + Returns: + output (VisImage): image object with visualizations. + """ + annos = dic.get("annotations", None) + if annos: + if "segmentation" in annos[0]: + masks = [x["segmentation"] for x in annos] + else: + masks = None + if "keypoints" in annos[0]: + keypts = [x["keypoints"] for x in annos] + keypts = np.array(keypts).reshape(len(annos), -1, 3) + else: + keypts = None + + boxes = [ + (BoxMode.convert(x["bbox"], x["bbox_mode"], BoxMode.XYXY_ABS) if len(x["bbox"]) == 4 else x["bbox"]) + for x in annos + ] + + colors = None + category_ids = [x["category_id"] for x in annos] + if self._instance_mode == ColorMode.SEGMENTATION and self.metadata.get("thing_colors"): + colors = [self._jitter([x / 255 for x in self.metadata.thing_colors[c]]) for c in category_ids] + names = self.metadata.get("thing_classes", None) + labels = _create_text_labels( + category_ids, + scores=None, + class_names=names, + is_crowd=[x.get("iscrowd", 0) for x in annos], + ) + self.overlay_instances( + labels=labels, + boxes=boxes, + masks=masks, + keypoints=keypts, + assigned_colors=colors, + ) + + sem_seg = dic.get("sem_seg", None) + if sem_seg is None and "sem_seg_file_name" in dic: + with open(dic["sem_seg_file_name"], "rb") as f: + sem_seg = Image.open(f) + sem_seg = np.asarray(sem_seg, dtype="uint8") + if sem_seg is not None: + self.draw_sem_seg(sem_seg, area_threshold=0, alpha=0.5) + + pan_seg = dic.get("pan_seg", None) + if pan_seg is None and "pan_seg_file_name" in dic: + with open(dic["pan_seg_file_name"], "rb") as f: + pan_seg = Image.open(f) + pan_seg = np.asarray(pan_seg) + from panopticapi.utils import rgb2id + + pan_seg = rgb2id(pan_seg) + if pan_seg is not None: + segments_info = dic["segments_info"] + pan_seg = torch.tensor(pan_seg) + self.draw_panoptic_seg(pan_seg, segments_info, area_threshold=0, alpha=0.5) + return self.output + + def overlay_instances( + self, + *, + boxes=None, + labels=None, + masks=None, + keypoints=None, + assigned_colors=None, + alpha=0.5, + ): + """ + Args: + boxes (Boxes, RotatedBoxes or ndarray): either a :class:`Boxes`, + or an Nx4 numpy array of XYXY_ABS format for the N objects in a single image, + or a :class:`RotatedBoxes`, + or an Nx5 numpy array of (x_center, y_center, width, height, angle_degrees) format + for the N objects in a single image, + labels (list[str]): the text to be displayed for each instance. + masks (masks-like object): Supported types are: + + :class:`detectron2.structures.BitMasks`. + * list[list[ndarray]]: contains the segmentation masks for all objects in one image. + The first level of the list corresponds to individual instances. The second + level to all the polygon that compose the instance, and the third level + to the polygon coordinates. The third level should have the format of + [x0, y0, x1, y1, ..., xn, yn] (n >= 3). + * list[ndarray]: each ndarray is a binary mask of shape (H, W). + * list[dict]: each dict is a COCO-style RLE. + keypoints (Keypoint or array like): an array-like object of shape (N, K, 3), + where the N is the number of instances and K is the number of keypoints. + The last dimension corresponds to (x, y, visibility or score). + assigned_colors (list[matplotlib.colors]): a list of colors, where each color + corresponds to each mask or box in the image. Refer to 'matplotlib.colors' + for full list of formats that the colors are accepted in. + Returns: + output (VisImage): image object with visualizations. + """ + num_instances = 0 + if boxes is not None: + boxes = self._convert_boxes(boxes) + num_instances = len(boxes) + if masks is not None: + masks = self._convert_masks(masks) + if num_instances: + assert len(masks) == num_instances + else: + num_instances = len(masks) + if keypoints is not None: + if num_instances: + assert len(keypoints) == num_instances + else: + num_instances = len(keypoints) + keypoints = self._convert_keypoints(keypoints) + if labels is not None: + assert len(labels) == num_instances + if assigned_colors is None: + assigned_colors = [random_color(rgb=True, maximum=1) for _ in range(num_instances)] + if num_instances == 0: + return self.output + if boxes is not None and boxes.shape[1] == 5: + return self.overlay_rotated_instances(boxes=boxes, labels=labels, assigned_colors=assigned_colors) + + # Display in largest to smallest order to reduce occlusion. + areas = None + if boxes is not None: + areas = np.prod(boxes[:, 2:] - boxes[:, :2], axis=1) + elif masks is not None: + areas = np.asarray([x.area() for x in masks]) + + if areas is not None: + sorted_idxs = np.argsort(-areas).tolist() + # Re-order overlapped instances in descending order. + boxes = boxes[sorted_idxs] if boxes is not None else None + labels = [labels[k] for k in sorted_idxs] if labels is not None else None + masks = [masks[idx] for idx in sorted_idxs] if masks is not None else None + assigned_colors = [assigned_colors[idx] for idx in sorted_idxs] + keypoints = keypoints[sorted_idxs] if keypoints is not None else None + + for i in range(num_instances): + color = assigned_colors[i] + if boxes is not None: + self.draw_box(boxes[i], edge_color=color) + + if masks is not None: + for segment in masks[i].polygons: + self.draw_polygon(segment.reshape(-1, 2), color, alpha=alpha) + + if labels is not None: + # first get a box + if boxes is not None: + x0, y0, x1, y1 = boxes[i] + text_pos = (x0, y0) # if drawing boxes, put text on the box corner. + horiz_align = "left" + elif masks is not None: + # skip small mask without polygon + if len(masks[i].polygons) == 0: + continue + + x0, y0, x1, y1 = masks[i].bbox() + + # draw text in the center (defined by median) when box is not drawn + # median is less sensitive to outliers. + text_pos = np.median(masks[i].mask.nonzero(), axis=1)[::-1] + horiz_align = "center" + else: + continue # drawing the box confidence for keypoints isn't very useful. + # for small objects, draw text at the side to avoid occlusion + instance_area = (y1 - y0) * (x1 - x0) + if instance_area < _SMALL_OBJECT_AREA_THRESH * self.output.scale or y1 - y0 < 40 * self.output.scale: + if y1 >= self.output.height - 5: + text_pos = (x1, y0) + else: + text_pos = (x0, y1) + + height_ratio = (y1 - y0) / np.sqrt(self.output.height * self.output.width) + lighter_color = self._change_color_brightness(color, brightness_factor=0.7) + font_size = np.clip((height_ratio - 0.02) / 0.08 + 1, 1.2, 2) * 0.5 * self._default_font_size + self.draw_text( + labels[i], + text_pos, + color=lighter_color, + horizontal_alignment=horiz_align, + font_size=font_size, + ) + + # draw keypoints + if keypoints is not None: + for keypoints_per_instance in keypoints: + self.draw_and_connect_keypoints(keypoints_per_instance) + + return self.output + + def overlay_rotated_instances(self, boxes=None, labels=None, assigned_colors=None): + """ + Args: + boxes (ndarray): an Nx5 numpy array of + (x_center, y_center, width, height, angle_degrees) format + for the N objects in a single image. + labels (list[str]): the text to be displayed for each instance. + assigned_colors (list[matplotlib.colors]): a list of colors, where each color + corresponds to each mask or box in the image. Refer to 'matplotlib.colors' + for full list of formats that the colors are accepted in. + + Returns: + output (VisImage): image object with visualizations. + """ + num_instances = len(boxes) + + if assigned_colors is None: + assigned_colors = [random_color(rgb=True, maximum=1) for _ in range(num_instances)] + if num_instances == 0: + return self.output + + # Display in largest to smallest order to reduce occlusion. + if boxes is not None: + areas = boxes[:, 2] * boxes[:, 3] + + sorted_idxs = np.argsort(-areas).tolist() + # Re-order overlapped instances in descending order. + boxes = boxes[sorted_idxs] + labels = [labels[k] for k in sorted_idxs] if labels is not None else None + colors = [assigned_colors[idx] for idx in sorted_idxs] + + for i in range(num_instances): + self.draw_rotated_box_with_label( + boxes[i], + edge_color=colors[i], + label=labels[i] if labels is not None else None, + ) + + return self.output + + def draw_and_connect_keypoints(self, keypoints): + """ + Draws keypoints of an instance and follows the rules for keypoint connections + to draw lines between appropriate keypoints. This follows color heuristics for + line color. + + Args: + keypoints (Tensor): a tensor of shape (K, 3), where K is the number of keypoints + and the last dimension corresponds to (x, y, probability). + + Returns: + output (VisImage): image object with visualizations. + """ + visible = {} + keypoint_names = self.metadata.get("keypoint_names") + for idx, keypoint in enumerate(keypoints): + # draw keypoint + x, y, prob = keypoint + if prob > self.keypoint_threshold: + self.draw_circle((x, y), color=_RED) + if keypoint_names: + keypoint_name = keypoint_names[idx] + visible[keypoint_name] = (x, y) + + if self.metadata.get("keypoint_connection_rules"): + for kp0, kp1, color in self.metadata.keypoint_connection_rules: + if kp0 in visible and kp1 in visible: + x0, y0 = visible[kp0] + x1, y1 = visible[kp1] + color = tuple(x / 255.0 for x in color) + self.draw_line([x0, x1], [y0, y1], color=color) + + # draw lines from nose to mid-shoulder and mid-shoulder to mid-hip + # Note that this strategy is specific to person keypoints. + # For other keypoints, it should just do nothing + try: + ls_x, ls_y = visible["left_shoulder"] + rs_x, rs_y = visible["right_shoulder"] + mid_shoulder_x, mid_shoulder_y = (ls_x + rs_x) / 2, (ls_y + rs_y) / 2 + except KeyError: + pass + else: + # draw line from nose to mid-shoulder + nose_x, nose_y = visible.get("nose", (None, None)) + if nose_x is not None: + self.draw_line([nose_x, mid_shoulder_x], [nose_y, mid_shoulder_y], color=_RED) + + try: + # draw line from mid-shoulder to mid-hip + lh_x, lh_y = visible["left_hip"] + rh_x, rh_y = visible["right_hip"] + except KeyError: + pass + else: + mid_hip_x, mid_hip_y = (lh_x + rh_x) / 2, (lh_y + rh_y) / 2 + self.draw_line([mid_hip_x, mid_shoulder_x], [mid_hip_y, mid_shoulder_y], color=_RED) + return self.output + + """ + Primitive drawing functions: + """ + + def draw_text( + self, + text, + position, + *, + font_size=None, + color="g", + horizontal_alignment="center", + rotation=0, + ): + """ + Args: + text (str): class label + position (tuple): a tuple of the x and y coordinates to place text on image. + font_size (int, optional): font of the text. If not provided, a font size + proportional to the image width is calculated and used. + color: color of the text. Refer to `matplotlib.colors` for full list + of formats that are accepted. + horizontal_alignment (str): see `matplotlib.text.Text` + rotation: rotation angle in degrees CCW + + Returns: + output (VisImage): image object with text drawn. + """ + if not font_size: + font_size = self._default_font_size + + # since the text background is dark, we don't want the text to be dark + color = np.maximum(list(mplc.to_rgb(color)), 0.2) + color[np.argmax(color)] = max(0.8, np.max(color)) + + x, y = position + self.output.ax.text( + x, + y, + text, + size=font_size * self.output.scale, + family="sans-serif", + bbox={"facecolor": "black", "alpha": 0.8, "pad": 0.7, "edgecolor": "none"}, + verticalalignment="top", + horizontalalignment=horizontal_alignment, + color=color, + zorder=10, + rotation=rotation, + ) + return self.output + + def draw_box(self, box_coord, alpha=0.5, edge_color="g", line_style="-"): + """ + Args: + box_coord (tuple): a tuple containing x0, y0, x1, y1 coordinates, where x0 and y0 + are the coordinates of the image's top left corner. x1 and y1 are the + coordinates of the image's bottom right corner. + alpha (float): blending efficient. Smaller values lead to more transparent masks. + edge_color: color of the outline of the box. Refer to `matplotlib.colors` + for full list of formats that are accepted. + line_style (string): the string to use to create the outline of the boxes. + + Returns: + output (VisImage): image object with box drawn. + """ + x0, y0, x1, y1 = box_coord + width = x1 - x0 + height = y1 - y0 + + linewidth = max(self._default_font_size / 4, 1) + + self.output.ax.add_patch( + mpl.patches.Rectangle( + (x0, y0), + width, + height, + fill=False, + edgecolor=edge_color, + linewidth=linewidth * self.output.scale, + alpha=alpha, + linestyle=line_style, + ) + ) + return self.output + + def draw_rotated_box_with_label(self, rotated_box, alpha=0.5, edge_color="g", line_style="-", label=None): + """ + Draw a rotated box with label on its top-left corner. + + Args: + rotated_box (tuple): a tuple containing (cnt_x, cnt_y, w, h, angle), + where cnt_x and cnt_y are the center coordinates of the box. + w and h are the width and height of the box. angle represents how + many degrees the box is rotated CCW with regard to the 0-degree box. + alpha (float): blending efficient. Smaller values lead to more transparent masks. + edge_color: color of the outline of the box. Refer to `matplotlib.colors` + for full list of formats that are accepted. + line_style (string): the string to use to create the outline of the boxes. + label (string): label for rotated box. It will not be rendered when set to None. + + Returns: + output (VisImage): image object with box drawn. + """ + cnt_x, cnt_y, w, h, angle = rotated_box + area = w * h + # use thinner lines when the box is small + linewidth = self._default_font_size / (6 if area < _SMALL_OBJECT_AREA_THRESH * self.output.scale else 3) + + theta = angle * math.pi / 180.0 + c = math.cos(theta) + s = math.sin(theta) + rect = [(-w / 2, h / 2), (-w / 2, -h / 2), (w / 2, -h / 2), (w / 2, h / 2)] + # x: left->right ; y: top->down + rotated_rect = [(s * yy + c * xx + cnt_x, c * yy - s * xx + cnt_y) for (xx, yy) in rect] + for k in range(4): + j = (k + 1) % 4 + self.draw_line( + [rotated_rect[k][0], rotated_rect[j][0]], + [rotated_rect[k][1], rotated_rect[j][1]], + color=edge_color, + linestyle="--" if k == 1 else line_style, + linewidth=linewidth, + ) + + if label is not None: + text_pos = rotated_rect[1] # topleft corner + + height_ratio = h / np.sqrt(self.output.height * self.output.width) + label_color = self._change_color_brightness(edge_color, brightness_factor=0.7) + font_size = np.clip((height_ratio - 0.02) / 0.08 + 1, 1.2, 2) * 0.5 * self._default_font_size + self.draw_text(label, text_pos, color=label_color, font_size=font_size, rotation=angle) + + return self.output + + def draw_circle(self, circle_coord, color, radius=3): + """ + Args: + circle_coord (list(int) or tuple(int)): contains the x and y coordinates + of the center of the circle. + color: color of the polygon. Refer to `matplotlib.colors` for a full list of + formats that are accepted. + radius (int): radius of the circle. + + Returns: + output (VisImage): image object with box drawn. + """ + x, y = circle_coord + self.output.ax.add_patch(mpl.patches.Circle(circle_coord, radius=radius, fill=True, color=color)) + return self.output + + def draw_line(self, x_data, y_data, color, linestyle="-", linewidth=None): + """ + Args: + x_data (list[int]): a list containing x values of all the points being drawn. + Length of list should match the length of y_data. + y_data (list[int]): a list containing y values of all the points being drawn. + Length of list should match the length of x_data. + color: color of the line. Refer to `matplotlib.colors` for a full list of + formats that are accepted. + linestyle: style of the line. Refer to `matplotlib.lines.Line2D` + for a full list of formats that are accepted. + linewidth (float or None): width of the line. When it's None, + a default value will be computed and used. + + Returns: + output (VisImage): image object with line drawn. + """ + if linewidth is None: + linewidth = self._default_font_size / 3 + linewidth = max(linewidth, 1) + self.output.ax.add_line( + mpl.lines.Line2D( + x_data, + y_data, + linewidth=linewidth * self.output.scale, + color=color, + linestyle=linestyle, + ) + ) + return self.output + + def draw_binary_mask( + self, + binary_mask, + color=None, + *, + edge_color=None, + text=None, + alpha=0.5, + area_threshold=10, + ): + """ + Args: + binary_mask (ndarray): numpy array of shape (H, W), where H is the image height and + W is the image width. Each value in the array is either a 0 or 1 value of uint8 + type. + color: color of the mask. Refer to `matplotlib.colors` for a full list of + formats that are accepted. If None, will pick a random color. + edge_color: color of the polygon edges. Refer to `matplotlib.colors` for a + full list of formats that are accepted. + text (str): if None, will be drawn on the object + alpha (float): blending efficient. Smaller values lead to more transparent masks. + area_threshold (float): a connected component smaller than this area will not be shown. + + Returns: + output (VisImage): image object with mask drawn. + """ + if color is None: + color = random_color(rgb=True, maximum=1) + color = mplc.to_rgb(color) + + has_valid_segment = False + binary_mask = binary_mask.astype("uint8") # opencv needs uint8 + mask = GenericMask(binary_mask, self.output.height, self.output.width) + shape2d = (binary_mask.shape[0], binary_mask.shape[1]) + + if not mask.has_holes: + # draw polygons for regular masks + for segment in mask.polygons: + area = mask_util.area(mask_util.frPyObjects([segment], shape2d[0], shape2d[1])) + if area < (area_threshold or 0): + continue + has_valid_segment = True + segment = segment.reshape(-1, 2) + self.draw_polygon(segment, color=color, edge_color=edge_color, alpha=alpha) + else: + # TODO: Use Path/PathPatch to draw vector graphics: + # https://stackoverflow.com/questions/8919719/how-to-plot-a-complex-polygon + rgba = np.zeros(shape2d + (4,), dtype="float32") + rgba[:, :, :3] = color + rgba[:, :, 3] = (mask.mask == 1).astype("float32") * alpha + has_valid_segment = True + self.output.ax.imshow(rgba, extent=(0, self.output.width, self.output.height, 0)) + + if text is not None and has_valid_segment: + lighter_color = self._change_color_brightness(color, brightness_factor=0.7) + self._draw_text_in_mask(binary_mask, text, lighter_color) + return self.output + + def draw_soft_mask(self, soft_mask, color=None, *, text=None, alpha=0.5): + """ + Args: + soft_mask (ndarray): float array of shape (H, W), each value in [0, 1]. + color: color of the mask. Refer to `matplotlib.colors` for a full list of + formats that are accepted. If None, will pick a random color. + text (str): if None, will be drawn on the object + alpha (float): blending efficient. Smaller values lead to more transparent masks. + + Returns: + output (VisImage): image object with mask drawn. + """ + if color is None: + color = random_color(rgb=True, maximum=1) + color = mplc.to_rgb(color) + + shape2d = (soft_mask.shape[0], soft_mask.shape[1]) + rgba = np.zeros(shape2d + (4,), dtype="float32") + rgba[:, :, :3] = color + rgba[:, :, 3] = soft_mask * alpha + self.output.ax.imshow(rgba, extent=(0, self.output.width, self.output.height, 0)) + + if text is not None: + lighter_color = self._change_color_brightness(color, brightness_factor=0.7) + binary_mask = (soft_mask > 0.5).astype("uint8") + self._draw_text_in_mask(binary_mask, text, lighter_color) + return self.output + + def draw_polygon(self, segment, color, edge_color=None, alpha=0.5): + """ + Args: + segment: numpy array of shape Nx2, containing all the points in the polygon. + color: color of the polygon. Refer to `matplotlib.colors` for a full list of + formats that are accepted. + edge_color: color of the polygon edges. Refer to `matplotlib.colors` for a + full list of formats that are accepted. If not provided, a darker shade + of the polygon color will be used instead. + alpha (float): blending efficient. Smaller values lead to more transparent masks. + + Returns: + output (VisImage): image object with polygon drawn. + """ + if edge_color is None: + # make edge color darker than the polygon color + if alpha > 0.8: + edge_color = self._change_color_brightness(color, brightness_factor=-0.7) + else: + edge_color = color + edge_color = mplc.to_rgb(edge_color) + (1,) + + polygon = mpl.patches.Polygon( + segment, + fill=True, + facecolor=mplc.to_rgb(color) + (alpha,), + edgecolor=edge_color, + linewidth=max(self._default_font_size // 15 * self.output.scale, 1), + ) + self.output.ax.add_patch(polygon) + return self.output + + """ + Internal methods: + """ + + def _jitter(self, color): + """ + Randomly modifies given color to produce a slightly different color than the color given. + + Args: + color (tuple[double]): a tuple of 3 elements, containing the RGB values of the color + picked. The values in the list are in the [0.0, 1.0] range. + + Returns: + jittered_color (tuple[double]): a tuple of 3 elements, containing the RGB values of the + color after being jittered. The values in the list are in the [0.0, 1.0] range. + """ + color = mplc.to_rgb(color) + vec = np.random.rand(3) + # better to do it in another color space + vec = vec / np.linalg.norm(vec) * 0.5 + res = np.clip(vec + color, 0, 1) + return tuple(res) + + def _create_grayscale_image(self, mask=None): + """ + Create a grayscale version of the original image. + The colors in masked area, if given, will be kept. + """ + img_bw = self.img.astype("f4").mean(axis=2) + img_bw = np.stack([img_bw] * 3, axis=2) + if mask is not None: + img_bw[mask] = self.img[mask] + return img_bw + + def _change_color_brightness(self, color, brightness_factor): + """ + Depending on the brightness_factor, gives a lighter or darker color i.e. a color with + less or more saturation than the original color. + + Args: + color: color of the polygon. Refer to `matplotlib.colors` for a full list of + formats that are accepted. + brightness_factor (float): a value in [-1.0, 1.0] range. A lightness factor of + 0 will correspond to no change, a factor in [-1.0, 0) range will result in + a darker color and a factor in (0, 1.0] range will result in a lighter color. + + Returns: + modified_color (tuple[double]): a tuple containing the RGB values of the + modified color. Each value in the tuple is in the [0.0, 1.0] range. + """ + assert brightness_factor >= -1.0 and brightness_factor <= 1.0 + color = mplc.to_rgb(color) + polygon_color = colorsys.rgb_to_hls(*mplc.to_rgb(color)) + modified_lightness = polygon_color[1] + (brightness_factor * polygon_color[1]) + modified_lightness = 0.0 if modified_lightness < 0.0 else modified_lightness + modified_lightness = 1.0 if modified_lightness > 1.0 else modified_lightness + modified_color = colorsys.hls_to_rgb(polygon_color[0], modified_lightness, polygon_color[2]) + return tuple(np.clip(modified_color, 0.0, 1.0)) + + def _convert_boxes(self, boxes): + """ + Convert different format of boxes to an NxB array, where B = 4 or 5 is the box dimension. + """ + if isinstance(boxes, Boxes): + return boxes.tensor.detach().numpy() + else: + return np.asarray(boxes) + + def _convert_masks(self, masks_or_polygons): + """ + Convert different format of masks or polygons to a tuple of masks and polygons. + + Returns: + list[GenericMask]: + """ + + m = masks_or_polygons + if isinstance(m, BitMasks): + m = m.tensor.numpy() + if isinstance(m, torch.Tensor): + m = m.numpy() + ret = [] + for x in m: + if isinstance(x, GenericMask): + ret.append(x) + else: + ret.append(GenericMask(x, self.output.height, self.output.width)) + return ret + + def _draw_text_in_mask(self, binary_mask, text, color): + """ + Find proper places to draw text given a binary mask. + """ + # TODO sometimes drawn on wrong objects. the heuristics here can improve. + _num_cc, cc_labels, stats, centroids = cv2.connectedComponentsWithStats(binary_mask, 8) + if stats[1:, -1].size == 0: + return + largest_component_id = np.argmax(stats[1:, -1]) + 1 + + # draw text on the largest component, as well as other very large components. + for cid in range(1, _num_cc): + if cid == largest_component_id or stats[cid, -1] > _LARGE_MASK_AREA_THRESH: + # median is more stable than centroid + # center = centroids[largest_component_id] + center = np.median((cc_labels == cid).nonzero(), axis=1)[::-1] + self.draw_text(text, center, color=color) + + def _convert_keypoints(self, keypoints): + if isinstance(keypoints, Keypoints): + keypoints = keypoints.tensor + keypoints = np.asarray(keypoints) + return keypoints + + def get_output(self): + """ + Returns: + output (VisImage): the image output containing the visualizations added + to the image. + """ + return self.output diff --git a/gradio/app.py b/gradio/app.py index c6b33d13..7cd8e655 100644 --- a/gradio/app.py +++ b/gradio/app.py @@ -1,63 +1,169 @@ import os +import uuid import cv2 import gradio as gr -from focoos import Focoos +from focoos.model_manager import ModelManager +from focoos.model_registry import ModelRegistry +from focoos.utils.vision import annotate_frame, annotate_image ASSETS_DIR = os.path.dirname(os.path.abspath(__file__)) + "/assets" +OUTPUT_DIR = os.path.dirname(os.path.abspath(__file__)) + "/output" +os.makedirs(OUTPUT_DIR, exist_ok=True) +SUBSAMPLE = 2 -focoos_models = [] -focoos = Focoos(api_key=os.getenv("FOCOOS_API_KEY")) -focoos_models = [model.ref for model in focoos.list_focoos_models()] + +model_registry = ModelRegistry() +focoos_models = list(model_registry.list_models()) loaded_models = {} image_examples = [ - ["fai-rtdetr-l-coco", f"{ASSETS_DIR}/pexels-abby-chung.jpg"], - ["fai-rtdetr-m-obj365", f"{ASSETS_DIR}/motogp.jpg"], - ["fai-rtdetr-s-coco", f"{ASSETS_DIR}/ADE_val_00000821.jpg"], - ["fai-m2f-m-ade", f"{ASSETS_DIR}/ADE_val_00000461.jpg"], - ["fai-m2f-l-coco-ins", f"{ASSETS_DIR}/ADE_val_00000034.jpg"], + [f"{ASSETS_DIR}/pexels-abby-chung.jpg", "fai-detr-l-coco"], + [f"{ASSETS_DIR}/motogp.jpg", "fai-detr-l-obj365"], + [f"{ASSETS_DIR}/ADE_val_00000821.jpg", "fai-detr-m-coco"], + [f"{ASSETS_DIR}/ADE_val_00000461.jpg", "fai-mf-m-ade"], + [f"{ASSETS_DIR}/ADE_val_00000034.jpg", "fai-mf-l-coco-ins"], ] -def run_inference(model_name, image, conf): - if not model_name or not image or not conf: - raise gr.Error("Model name and image are required") +def run_inference(image, model_name: str, conf: float, progress=gr.Progress()): + assert model_name is not None, "model_name is required" + assert model_name in model_registry.list_models(), "model_name is not valid" if model_name not in loaded_models: - model = focoos.get_remote_model(model_name) + model = ModelManager.get(model_name) loaded_models[model_name] = model else: model = loaded_models[model_name] - detections, annotated_image = model.infer(image, conf, annotate=True) - return cv2.cvtColor(annotated_image, cv2.COLOR_BGR2RGB), detections.model_dump() - - -with gr.Blocks() as demo: - gr.Markdown("## ๐Ÿ”ฅ Cloud Inference Focoos Foundational Models") - with gr.Row(): - with gr.Column(): - image = gr.Image(type="filepath") - model_name = gr.Dropdown( - choices=list(focoos_models), - label="Model", - value=list(focoos_models)[0], - ) - conf = gr.Slider(maximum=0.9, minimum=0, value=0.5, label="Confidencte threshold") - start_btn = gr.Button("Run Inference") - with gr.Column(): - output_image = gr.Image(type="pil") - output_detections = gr.JSON() - examples = gr.Examples( - fn=run_inference, - inputs=[model_name, image], - outputs=[output_image], - examples=image_examples, + detections = model(image, threshold=conf) + annotated_image = annotate_image(image, detections, task=model.task, classes=model.classes) + return annotated_image, detections.model_dump() + + +def run_video_inference( + video_path: str, + model_name: str, + threshold: float, + progress=gr.Progress(), +): + assert video_path is not None, "video_path is required" + assert model_name is not None, "model_name is required" + assert model_name in model_registry.list_models(), "model_name is not valid" + + progress(0, desc="Load Model...") + if model_name not in loaded_models: + model = ModelManager.get(model_name) + loaded_models[model_name] = model + else: + model = loaded_models[model_name] + + cap = cv2.VideoCapture(video_path) + + # This means we will output mp4 videos + video_codec = cv2.VideoWriter_fourcc(*"mp4v") # type: ignore + fps = int(cap.get(cv2.CAP_PROP_FPS)) + desired_fps = fps + + width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)) + height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) + total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) + + desired_width = int(width) + desired_height = int(height) + + print( + f"video: {video_path} fps: {fps}, total_frames: {total_frames}, desired_fps: {desired_fps}, width: {desired_width}, height: {desired_height}" ) - start_btn.click( - fn=run_inference, - inputs=[model_name, image, conf], - outputs=[output_image, output_detections], + + progress(0.1, desc="Initializing video...") + + # Use UUID to create a unique video file + output_video_name = f"{OUTPUT_DIR}/output_{uuid.uuid4()}.mp4" + + # Output Video + output_video = cv2.VideoWriter(output_video_name, video_codec, desired_fps, (desired_width, desired_height)) # type: ignore + + iterating, frame = cap.read() + n_frames = 0 + last_latency = None + + progress(0.15, desc="Processing frames...") + + while iterating: + if not cap.isOpened(): + print("Video ended") + break + + if frame is None: + iterating, frame = cap.read() + continue + + frame = cv2.resize(frame, (desired_width, desired_height)) + frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) + res = model(frame, threshold=threshold) + last_latency = res.latency.get("inference") if res.latency is not None else None + + annotated_frame = annotate_frame(frame, res, task=model.task, classes=model.classes) + + # Write frame directly to video + output_video.write(annotated_frame[:, :, ::-1]) + + n_frames += 1 + + # Update progress + progress_value = 0.15 + (0.8 * n_frames / total_frames) + progress(progress_value, desc=f"Processing frame {n_frames}/{total_frames}") + + iterating, frame = cap.read() + + progress(0.95, desc="Finalizing video...") + + cap.release() + output_video.release() + + progress(1.0, desc="Completed!") + print(f"Video processed: {output_video_name}, total frames: {n_frames}") + + return ( + output_video_name, + { + "total_frames": n_frames, + "latency(ms)": last_latency, + }, ) +image_interface = gr.Interface( + fn=run_inference, + inputs=[ + gr.Image(type="numpy"), + gr.Dropdown( + choices=list(focoos_models), + label="Model", + value=list(focoos_models)[0], + ), + gr.Slider(maximum=0.9, minimum=0, value=0.5, label="Confidence threshold"), + ], + outputs=[gr.Image(type="pil"), gr.JSON()], + examples=image_examples, + flagging_mode="never", +) + +video_interface = gr.Interface( + fn=run_video_inference, + inputs=[ + gr.Video(), + gr.Dropdown(label="model", choices=list(focoos_models), value=list(focoos_models)[0]), + gr.Slider(label="confidence threshold", minimum=0, maximum=1, value=0.5), + ], + flagging_mode="never", + outputs=[gr.Video(streaming=True, autoplay=True, format="mp4"), gr.JSON()], + description="Upload a video to run inference", +) + + +demo = gr.TabbedInterface( + title="Focoos Pretrained Models", + interface_list=[image_interface, video_interface], + tab_names=["Image Inference", "Video Inference"], +) demo.launch() diff --git a/mkdocs.yaml b/mkdocs.yaml index 4c09b2fe..8b6210fb 100644 --- a/mkdocs.yaml +++ b/mkdocs.yaml @@ -34,36 +34,42 @@ plugins: nav: - Focoos AI: - Welcome: index.md - - Installation: setup.md - - Models: models.md - - How To: - - Manage Dataset: howto/create_dataset.md - - Create and Train Model: howto/personalize_model.md - - Manage Models: howto/manage_models.md - - Use Model: howto/use_model.md - - Manage User: howto/manage_user.md - - Focoos Models: - - Overview: models.md - - Semantic segmentation: - - fai-m2f-l-ade: models/fai-m2f-l-ade.md - - fai-m2f-m-ade: models/fai-m2f-m-ade.md - - fai-m2f-s-ade: models/fai-m2f-s-ade.md - - Object detection: - - fai-rtdetr-l-coco: models/fai-rtdetr-l-coco.md - - fai-rtdetr-m-coco: models/fai-rtdetr-m-coco.md - - fai-rtdetr-s-coco: models/fai-rtdetr-s-coco.md - - fai-rtdetr-n-coco: models/fai-rtdetr-n-coco.md - - fai-rtdetr-m-obj365: models/fai-rtdetr-m-obj365.md - - Instance_segmentation: - - fai-m2f-l-coco-ins: models/fai-m2f-l-coco-ins.md + - Setup: setup.md + - Inference: inference.md + - Training: training.md + - Concepts: concepts.md + - HUB: + - Overview: hub/overview.md + - HUB: hub/hub.md + - Remote Inference: hub/remote_inference.md + - Models: + - Overview: models/models.md + - fai-detr: models/fai_detr.md + - fai-mf: models/fai_mf.md + - fai-cls: models/fai_cls.md + - bisenetformer: models/bisenetformer.md + + + - Contribute: development/contributing.md + - API Reference: - - Focoos: api/focoos.md - - Config: api/config.md - - RemoteModel: api/remote_model.md - - LocalModel: api/local_model.md - - RemoteDataset: api/remote_dataset.md - - Runtime: api/runtime.md - - Ports: api/ports.md + - ModelManager: api/model_manager.md + - FocoosModel: + - FocoosModel: api/focoos_model.md + - BaseModelNN: api/base_model.md + + - ModelRegistry: api/model_registry.md + - InferModel: + - InferModel: api/infer_model.md + - runtimes: api/runtimes.md + - Processor: api/processor.md + - FocoosHUB: api/hub.md + - Trainer: + - evaluation: api/trainer_evaluation.md + - hooks: api/trainer_hooks.md + - AutoDataset: api/auto_dataset.md + - ports: api/ports.md + - config: api/config.md markdown_extensions: - pymdownx.highlight: diff --git a/notebooks/assets/ade_val_034.jpg b/notebooks/assets/ade_val_034.jpg deleted file mode 100644 index 1c58c9e5..00000000 Binary files a/notebooks/assets/ade_val_034.jpg and /dev/null differ diff --git a/notebooks/assets/aquarium.jpg b/notebooks/assets/aquarium.jpg deleted file mode 100644 index c7dbc037..00000000 Binary files a/notebooks/assets/aquarium.jpg and /dev/null differ diff --git a/notebooks/assets/football.jpg b/notebooks/assets/football.jpg deleted file mode 100644 index 76c5bfe7..00000000 Binary files a/notebooks/assets/football.jpg and /dev/null differ diff --git a/notebooks/dataset.ipynb b/notebooks/dataset.ipynb deleted file mode 100644 index 09d240af..00000000 --- a/notebooks/dataset.ipynb +++ /dev/null @@ -1,293 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Download Datasets from external sources" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "%uv pip install dataset-tools roboflow" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "%uv pip install setuptools" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Download from Dataset-Ninja (supervisely)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "import dataset_tools as dtools\n", - "\n", - "dtools.download(dataset=\"dacl10k\", dst_dir=\"./datasets/dataset-ninja/\")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Download from Roboflow Universe" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "import os\n", - "\n", - "from roboflow import Roboflow\n", - "\n", - "rf = Roboflow(api_key=os.getenv(\"ROBOFLOW_API_KEY\"))\n", - "project = rf.workspace(\"roboflow-58fyf\").project(\"rock-paper-scissors-sxsw\")\n", - "version = project.version(14)\n", - "dataset = version.download(\"coco\")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# ๐Ÿ Setup Focoos" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "%pip install 'focoos @ git+https://github.com/FocoosAI/focoos.git'" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "%uv pip install -e ..[cpu] " - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Focoos Cloud Dataset Management\n", - "\n", - "This section covers the steps to see the datasets available on the FocoosAI platform and the creation of user datasets.\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "import os\n", - "\n", - "from focoos import Focoos\n", - "\n", - "focoos = Focoos()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Get the list of shared datasets" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "import os\n", - "\n", - "from focoos import Focoos\n", - "\n", - "focoos = Focoos()\n", - "datasets = focoos.list_shared_datasets()\n", - "\n", - "for dataset in datasets:\n", - " print(f\"Name: {dataset.name}\")\n", - " print(f\"Reference: {dataset.ref}\")\n", - " print(f\"Task: {dataset.task}\")\n", - " print(f\"Description: {dataset.description}\")\n", - " print(\"-\" * 50)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# User Dataset" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "import os\n", - "\n", - "from focoos import Focoos\n", - "\n", - "focoos = Focoos(api_key=os.getenv(\"FOCOOS_API_KEY\"))\n", - "\n", - "datasets = focoos.list_datasets(include_shared=False)\n", - "for dataset in datasets:\n", - " print(f\"Name: {dataset.name}\")\n", - " print(f\"Reference: {dataset.ref}\")\n", - " print(f\"Task: {dataset.task}\")\n", - " print(f\"Description: {dataset.description}\")\n", - " print(f\"spec: {dataset.spec}\")\n", - " print(\"-\" * 50)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Delete datasets" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "import os\n", - "\n", - "from focoos import Focoos\n", - "\n", - "focoos = Focoos(api_key=os.getenv(\"FOCOOS_API_KEY\"))\n", - "\n", - "datasets = focoos.list_datasets(include_shared=False)\n", - "refs = [ds.ref for ds in datasets]\n", - "for ref in refs:\n", - " ds = focoos.get_remote_dataset(ref)\n", - " ds.delete_data()\n", - " ds.delete()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "focoos.get_user_info()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Create and upload a dataset" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "from focoos import DatasetLayout, Focoos, FocoosTask\n", - "\n", - "focoos = Focoos()\n", - "\n", - "ds = focoos.add_remote_dataset(\n", - " name=\"aeroscapes\", description=\"AeroScapes\", layout=DatasetLayout.SUPERVISELY, task=FocoosTask.SEMSEG\n", - ")\n", - "ds_spec = ds.upload_data(\"./datasets/dataset-ninja/aeroscapes1.zip\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "from focoos import DatasetLayout, Focoos, FocoosTask\n", - "\n", - "focoos = Focoos()\n", - "\n", - "ds = focoos.add_remote_dataset(\n", - " name=\"ballons\", description=\"Ballons\", layout=DatasetLayout.ROBOFLOW_SEG, task=FocoosTask.SEMSEG\n", - ")\n", - "ds_spec = ds.upload_data(\"./.data/balloons-roboflow-sem.zip\")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Download dataset" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "import os\n", - "\n", - "from focoos import Focoos\n", - "\n", - "focoos = Focoos()\n", - "_datasets = focoos.list_datasets(include_shared=False)\n", - "ds = focoos.get_remote_dataset(_datasets[0].ref)\n", - "ds.download_data(\"./datasets\")" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": ".venv", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.10.12" - } - }, - "nbformat": 4, - "nbformat_minor": 2 -} diff --git a/notebooks/image.jpg b/notebooks/image.jpg deleted file mode 100644 index db400c3c..00000000 Binary files a/notebooks/image.jpg and /dev/null differ diff --git a/notebooks/inference.ipynb b/notebooks/inference.ipynb deleted file mode 100644 index 0685a2d7..00000000 --- a/notebooks/inference.ipynb +++ /dev/null @@ -1,308 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# ๐Ÿ Setup Focoos" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "%pip install 'focoos @ git+https://github.com/FocoosAI/focoos.git'\n", - "\n", - "# If you want to run it locally using CPU you can install the package with the following command:\n", - "# %pip install 'focoos[cpu] @ git+https://github.com/FocoosAI/focoos.git'\n", - "\n", - "# If you want to run it locally using GPU you can install the package with the following command:\n", - "# %pip install 'focoos[cuda] @ git+https://github.com/FocoosAI/focoos.git'" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "import os\n", - "\n", - "if not os.path.exists(\"image.jpg\"):\n", - " print(\"Downloading image.jpg\")\n", - " !curl https://www.ondacinema.it/images/serial/xl/howimetyourmother-fotoxl.jpg -o image.jpg\n", - "image_path = \"image.jpg\"" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# ๐Ÿค– Playground with Focoos Models\n", - "\n", - "See the list of available models on the [Focoos Models](https://focoosai.github.io/focoos/models/) page.\n" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Setup the Focoos client" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "from pprint import pprint\n", - "\n", - "from PIL import Image\n", - "\n", - "from focoos import Focoos, RuntimeTypes\n", - "\n", - "focoos = Focoos(api_key=\"\")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Remote Inference\n", - "This section demonstrates how to perform remote inference using a model from the Focoos platform.\n", - "We will load a remote model (can be a pre-trained model or a custom user model), and then run inference on a sample image with focoos API.\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "model_ref = \"fai-rtdetr-m-obj365\"\n", - "\n", - "model = focoos.get_remote_model(model_ref)\n", - "\n", - "output, preview = model.infer(image_path, threshold=0.6, annotate=True)\n", - "\n", - "Image.fromarray(preview)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Local Inference\n", - "\n", - "This section demonstrates how to perform local inference using a model from the Focoos platform. \n", - "We will load a model, deploy it locally, and then run inference on a sample image.\n" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "๐Ÿ”ง **NOTE**: To run the local inference, you need to install one of the extras modules.\n", - "# Available Runtimes and Execution Providers\n", - "\n", - "| RuntimeType | Extra | Runtime | Compatible Devices | Available ExecutionProvider |\n", - "|------------|-------|---------|-------------------|---------------------------|\n", - "| ONNX_CUDA32 | `[cuda]` | onnxruntime CUDA | NVIDIA GPUs | CUDAExecutionProvider |\n", - "| ONNX_TRT32 | `[tensorrt]` | onnxruntime TRT | NVIDIA GPUs (Optimized) | CUDAExecutionProvider, TensorrtExecutionProvider |\n", - "| ONNX_TRT16 | `[tensorrt]` | onnxruntime TRT | NVIDIA GPUs (Optimized) | CUDAExecutionProvider, TensorrtExecutionProvider |\n", - "| ONNX_CPU | `[cpu]` | onnxruntime CPU | CPU (x86, ARM), M1, M2, M3 (Apple Silicon) | CPUExecutionProvider, CoreMLExecutionProvider, AzureExecutionProvider |\n", - "| ONNX_COREML | `[cpu]` | onnxruntime CPU | M1, M2, M3 (Apple Silicon) | CoreMLExecutionProvider, CPUExecutionProvider |\n", - "| TORCHSCRIPT_32 | `[torch]` | torchscript | CPU, NVIDIA GPUs | - |\n", - "\n", - "To install the extras modules, use the command: \n", - "\n", - "```bash \n", - "pip install 'focoos[{{extra-name}}] @ git+https://github.com/FocoosAI/focoos.git'\n", - "```\n", - "\n", - "# We will use the cpu as an example, feel free to choose the one that best fits your needs\n", - "%pip install 'focoos[cpu] @ git+https://github.com/FocoosAI/focoos.git'" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Rerun the kernel to reload the modules with the new dependencies\n" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Inference with OnnxRuntime (CPU)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "model_ref = \"fai-rtdetr-m-obj365\"\n", - "\n", - "model = focoos.get_local_model(model_ref, runtime_type=RuntimeTypes.ONNX_CPU)\n", - "\n", - "latency = model.benchmark(iterations=10, size=640)\n", - "pprint(latency)\n", - "\n", - "output, preview = model.infer(image_path, threshold=0.6, annotate=True)\n", - "pprint(output.detections)\n", - "pprint(output.latency)\n", - "\n", - "Image.fromarray(preview)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Inference with OnnxRuntime (CoreML)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "model_ref = \"fai-rtdetr-m-obj365\"\n", - "\n", - "model = focoos.get_local_model(model_ref, runtime_type=RuntimeTypes.ONNX_COREML)\n", - "\n", - "latency = model.benchmark(iterations=10, size=640)\n", - "pprint(latency)\n", - "\n", - "output, preview = model.infer(image_path, threshold=0.6, annotate=True)\n", - "pprint(output.detections)\n", - "pprint(output.latency)\n", - "\n", - "Image.fromarray(preview)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Inference with TorchscriptRuntime (CUDA32)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# To run the inference, you need to install the torch extra module\n", - "# %pip install 'focoos[torch] @ git+https://github.com/FocoosAI/focoos.git'\n", - "# Rerun the kernel to reload the modules with the new dependencies\n", - "\n", - "from pprint import pprint\n", - "\n", - "model_ref = \"fai-rtdetr-m-obj365\"\n", - "\n", - "model = focoos.get_local_model(model_ref, runtime_type=RuntimeTypes.TORCHSCRIPT_32)\n", - "\n", - "latency = model.benchmark(iterations=10, size=640)\n", - "pprint(latency)\n", - "\n", - "output, preview = model.infer(image_path, threshold=0.6, annotate=True)\n", - "pprint(output.detections)\n", - "pprint(output.latency)\n", - "\n", - "Image.fromarray(preview)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Inference with OnnxRuntime (CUDA32)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# To run the inference, you need to install the torch extra module\n", - "# %pip install 'focoos[cuda] @ git+https://github.com/FocoosAI/focoos.git'\n", - "# Rerun the kernel to reload the modules with the new dependencies\n", - "\n", - "model_ref = \"fai-rtdetr-m-obj365\"\n", - "\n", - "model = focoos.get_local_model(model_ref, runtime_type=RuntimeTypes.ONNX_CUDA32)\n", - "\n", - "latency = model.benchmark(iterations=10, size=640)\n", - "pprint(latency)\n", - "# pprint(latency)\n", - "output, preview = model.infer(image_path, threshold=0.6, annotate=True)\n", - "pprint(output.detections)\n", - "pprint(output.latency)\n", - "\n", - "Image.fromarray(preview)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Inference with OnnxRuntime (TensorRT) (FP16)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# To run the inference, you need to install the torch extra module\n", - "# %pip install 'focoos[tensorrt] @ git+https://github.com/FocoosAI/focoos.git'\n", - "# Rerun the kernel to reload the modules with the new dependencies\n", - "\n", - "model_ref = \"fai-rtdetr-m-obj365\"\n", - "\n", - "model = focoos.get_local_model(model_ref, runtime_type=RuntimeTypes.ONNX_TRT16)\n", - "\n", - "latency = model.benchmark(iterations=10, size=640)\n", - "pprint(latency)\n", - "# pprint(latency)\n", - "output, preview = model.infer(image_path, threshold=0.6, annotate=True)\n", - "pprint(output.detections)\n", - "pprint(output.latency)\n", - "\n", - "Image.fromarray(preview)" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": ".venv", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.12.8" - } - }, - "nbformat": 4, - "nbformat_minor": 2 -} diff --git a/notebooks/model_management.ipynb b/notebooks/model_management.ipynb deleted file mode 100644 index 8a4c0839..00000000 --- a/notebooks/model_management.ipynb +++ /dev/null @@ -1,198 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# ๐Ÿ Setup Focoos" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "%pip install 'focoos @ git+https://github.com/FocoosAI/focoos.git'" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Model Management\n", - "\n", - "This section covers the steps to monitor the status of models on the FocoosAI platform.\n", - "\n", - "For training, see the training examples in `training.ipynb`." - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Set up the environment" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "from focoos import Focoos\n", - "\n", - "focoos = Focoos(api_key=\"\")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## List Focoos models\n", - "\n", - "To list all the models available on the FocoosAI platform, you can use the following code:\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "focoos_models = focoos.list_focoos_models()\n", - "for model in focoos_models:\n", - " print(f\"Name: {model.name}\")\n", - " print(f\"Reference: {model.ref}\")\n", - " print(f\"Status: {model.status}\")\n", - " print(f\"Task: {model.task}\")\n", - " print(f\"Description: {model.description}\")\n", - " print(\"-\" * 50)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## List all your models\n", - "\n", - "To list all your models, the library provides a list_models function. This function will return a list of Model objects." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "models = focoos.list_models()\n", - "for model in models:\n", - " print(f\"Name: {model.name}\")\n", - " print(f\"Reference: {model.ref}\")\n", - " print(f\"Status: {model.status}\")\n", - " print(f\"Task: {model.task}\")\n", - " print(f\"Description: {model.description}\")\n", - " print(f\"Focoos Model: {model.focoos_model}\")\n", - " print(\"-\" * 50)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# Filter the models by status\n", - "STATUS = \"TRAINING_COMPLETED\" # choose of of the following: CREATED, TRAINING_RUNNING, TRAINING_COMPLETED, TRAINING_ERROR, TRAINING_STOPPED\n", - "filtered_models = [model for model in models if model.status == STATUS]\n", - "\n", - "for model in filtered_models:\n", - " print(f\"Name: {model.name}\")\n", - " print(f\"Reference: {model.ref}\")\n", - " print(f\"Status: {model.status}\")\n", - " print(f\"Task: {model.task}\")\n", - " print(f\"Description: {model.description}\")\n", - " print(\"-\" * 50)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# See the metrics for a model\n", - "To see the validation metrics of a model, you can use the metrics method on the model object." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# Ensure there is at least one model to get the reference of\n", - "if len(models) > 0:\n", - " model = focoos.get_remote_model(models[0].ref)\n", - "else:\n", - " model = focoos.get_remote_model(focoos_models[0].ref)\n", - "\n", - "metrics = model.metrics()\n", - "\n", - "if metrics.best_valid_metric:\n", - " print(\"Best validation metrics:\")\n", - " for k, v in metrics.best_valid_metric.items():\n", - " print(f\" {k}: {v}\")\n", - "\n", - "if metrics.valid_metrics:\n", - " print(\"Last iteration validation metrics:\")\n", - " for k, v in metrics.valid_metrics[-1].items():\n", - " print(f\" {k}: {v}\")\n", - "\n", - "if metrics.train_metrics:\n", - " print(\"Last iteration training metrics:\")\n", - " for k, v in metrics.train_metrics[-1].items():\n", - " print(f\" {k}: {v}\")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Delete a model\n", - "To delete a model, you can use the [`delete_model` method](../../api/remote_model/#focoos.remote_model.RemoteModel.delete_model) on the model object.\n", - "\n", - "**WARNING**: This action is irreversible and the model will be deleted forever from the platform.\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "model = focoos.get_remote_model(\"efa857f071074118\")\n", - "model.delete_model()" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": ".venv", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.12.8" - } - }, - "nbformat": 4, - "nbformat_minor": 2 -} diff --git a/notebooks/training.ipynb b/notebooks/training.ipynb deleted file mode 100644 index 0bfd0fa4..00000000 --- a/notebooks/training.ipynb +++ /dev/null @@ -1,337 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# ๐Ÿ Setup Focoos" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "%pip install 'focoos @ git+https://github.com/FocoosAI/focoos.git'\n", - "\n", - "# If you want to run it locally using CPU you can install the package with the following command:\n", - "# %pip install 'focoos[cpu] @ git+https://github.com/FocoosAI/focoos.git'\n", - "\n", - "# If you want to run it locally using GPU you can install the package with the following command:\n", - "# %pip install 'focoos[cuda] @ git+https://github.com/FocoosAI/focoos.git'" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "import os\n", - "\n", - "if not os.path.exists(\"image.jpg\"):\n", - " print(\"Downloading image.jpg\")\n", - " !curl https://www.ondacinema.it/images/serial/xl/howimetyourmother-fotoxl.jpg -o image.jpg\n", - "image_path = \"image.jpg\"" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# ๐ŸŽจ Personalize your model\n", - "\n", - "This section covers the steps to create a model and train it in the cloud using the focoos library. The following example demonstrates how to interact with the Focoos API to manage models, datasets, and training jobs.\n", - "\n", - "In this guide, we will perform the following steps:\n", - "\n", - "0. ๐Ÿ Connect with Focoos\n", - "1. ๐Ÿ“ฆ Load or select a dataset\n", - "2. ๐ŸŽฏ Create a model\n", - "3. ๐Ÿƒโ€โ™‚๏ธ Train the model\n", - "4. ๐Ÿ“Š Visualize training metrics\n", - "5. ๐Ÿงช Test your model\n" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## ๐Ÿ Connect with Focoos" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "from pprint import pprint\n", - "\n", - "from PIL import Image\n", - "\n", - "from focoos import Focoos\n", - "\n", - "focoos = Focoos(api_key=\"\")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## ๐Ÿ“ฆ Load or select a dataset\n", - "\n", - "Currently, we are not supporting dataset creation from the SDK (it's coming really soon) and you can only use a dataset already available on the platform. To upload your own dataset, you can write us a mail to info@focoos.ai and we will load your dataset on the platform on your private workspace (your data will not be shared with anyone and not used for any other purpose than training your model).\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "datasets = focoos.list_shared_datasets()\n", - "\n", - "for dataset in datasets:\n", - " print(f\"Name: {dataset.name}\")\n", - " print(f\"Reference: {dataset.ref}\")\n", - " print(f\"Task: {dataset.task}\")\n", - " print(f\"Description: {dataset.description}\")\n", - " print(\"-\" * 50)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# select the dataset you want to use for training\n", - "dataset_ref = \"7b7c0ed8cf804f1d\"" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## ๐ŸŽฏ Create a model\n", - "\n", - "The first step to personalize your model is to create a model. You can create a model by calling the new_model method on the Focoos object. You can choose the model you want to personalize from the list of Focoos Models available on the platform. Make sure to select the correct model for your task." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "model = focoos.new_model(\n", - " name=\"my-model\",\n", - " description=\"my-model-description\",\n", - " focoos_model=\"fai-rtdetr-m-obj365\",\n", - ")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "This function returns a new RemoteModel object that you can use to train the model and to perform remote inference." - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## ๐Ÿƒโ€โ™‚๏ธ Train the model\n", - "\n", - "The next step is to train the model. You can train the model by calling the train method on the RemoteModel object. You can choose the dataset you want to use for training and the instance type you want to use for training." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "from focoos import Hyperparameters\n", - "\n", - "res = model.train(\n", - " dataset_ref=dataset_ref,\n", - " hyperparameters=Hyperparameters(\n", - " learning_rate=0.0001, # custom learning rate\n", - " batch_size=16, # custom batch size\n", - " max_iters=1500, # custom max iterations\n", - " ),\n", - ")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "We provide you with a notebook monitor to track the training progress. You can use it to monitor the training progress and to get the training logs." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "model.notebook_monitor_train(interval=30, plot_metrics=True)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Or if you prefer the hard way, you can get the training logs by calling the train_logs method on the RemoteModel object." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "logs = model.train_logs()\n", - "pprint(logs)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "If for some reason you need to stop the training, you can do so by calling the stop_train method on the RemoteModel object." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "model.stop_training()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## ๐Ÿ“Š Visualize training metrics" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "from focoos.utils.metrics import MetricsVisualizer\n", - "\n", - "metrics = model.metrics()\n", - "visualizer = MetricsVisualizer(metrics)\n", - "visualizer.log_metrics()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "visualizer.notebook_plot_training_metrics()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## ๐Ÿงช Test your model\n" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Remote Inference" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "result, preview = model.infer(image_path, threshold=0.6, annotate=True)\n", - "\n", - "Image.fromarray(preview)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Local Inference\n", - "Remember, to perform local inference, you need to install the package with one of the extra modules (`[cpu]`, `[torch]`, `[cuda]`, `[tensorrt]`). See the [installation](./setup.md) page or the `inference.ipynb` notebook for more details." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "%pip install 'focoos[cpu] @ git+https://github.com/FocoosAI/focoos.git'\n", - "# Rerun the kernel to reload the modules with the new dependencies" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "model = focoos.get_local_model(model.model_ref) # get the local model\n", - "\n", - "result, _ = model.infer(image_path, threshold=0.5, annotate=False)\n", - "\n", - "for det in result.detections:\n", - " print(f\"Found {det.label} with confidence {det.conf:.2f}\")\n", - " print(f\"Bounding box: {det.bbox}\")\n", - " if det.mask:\n", - " print(\"Instance segmentation mask included\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [] - } - ], - "metadata": { - "kernelspec": { - "display_name": ".venv", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.12.8" - } - }, - "nbformat": 4, - "nbformat_minor": 2 -} diff --git a/notebooks/user_info.ipynb b/notebooks/user_info.ipynb deleted file mode 100644 index 528d2927..00000000 --- a/notebooks/user_info.ipynb +++ /dev/null @@ -1,122 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# ๐Ÿ Setup Focoos" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "%uv pip install -e .." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "%pip install 'focoos @ git+https://github.com/FocoosAI/focoos.git'" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# User Management\n", - "\n", - "This section covers the steps to monitor your status on the FocoosAI platform." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "from focoos import Focoos\n", - "\n", - "focoos = Focoos(api_key=\"\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "user_info = focoos.get_user_info()\n", - "\n", - "print(f\"Email: {user_info.email}\")\n", - "print(f\"Created at: {user_info.created_at}\")\n", - "print(f\"Updated at: {user_info.updated_at}\")\n", - "if user_info.company:\n", - " print(f\"Company: {user_info.company}\")\n", - "\n", - "print(\"\\nQuotas:\")\n", - "print(f\"Total inferences: {user_info.quotas.total_inferences}\")\n", - "print(f\"Max inferences: {user_info.quotas.max_inferences}\")\n", - "print(f\"Used storage (GB): {user_info.quotas.used_storage_gb}\")\n", - "print(f\"Max storage (GB): {user_info.quotas.max_storage_gb}\")\n", - "print(f\"Active training jobs: {user_info.quotas.active_training_jobs}\")\n", - "print(f\"Max active training jobs: {user_info.quotas.max_active_training_jobs}\")\n", - "print(f\"Used MLG4DNXLarge training jobs hours: {user_info.quotas.used_mlg4dnxlarge_training_jobs_hours}\")\n", - "print(f\"Max MLG4DNXLarge training jobs hours: {user_info.quotas.max_mlg4dnxlarge_training_jobs_hours}\")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## System Info" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "from focoos import get_system_info\n", - "\n", - "system_info = get_system_info()\n", - "\n", - "system_info.pretty_print()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [] - } - ], - "metadata": { - "kernelspec": { - "display_name": ".venv", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.10.12" - } - }, - "nbformat": 4, - "nbformat_minor": 2 -} diff --git a/ops/test_export.py b/ops/test_export.py new file mode 100644 index 00000000..839c26bc --- /dev/null +++ b/ops/test_export.py @@ -0,0 +1,186 @@ +import os +import tempfile +from pathlib import Path +from typing import List, Optional, Tuple, Union + +import numpy as np +import torch + +from focoos.infer.infer_model import InferModel +from focoos.model_manager import ModelManager +from focoos.ports import RuntimeType +from focoos.utils.logger import get_logger + +logger = get_logger("TestExport") + + +def list_files_with_extensions_recursively( + base_dir: Union[str, Path], extensions: Optional[List[str]] = None +) -> List[Path]: + """ + Generate a list of file paths with specific extensions recursively starting from a base directory. + + Parameters: + base_directory (Union[str, Path]): The directory to start the recursive search from. + extensions (Optional[List[str]]): List of file extensions to filter by. If None, all files will be included. + + Returns: + List[Path]: A list of Path objects representing the file paths that match the criteria. + """ + base_dir = Path(base_dir) + file_paths = [] + + if extensions: + for extension in extensions: + if extension.startswith("."): + extension = extension[1:] + _glob = f"*.{extension}" + file_paths.extend(base_dir.rglob(_glob)) + else: + file_paths.extend(base_dir.rglob("*")) + + return [path for path in file_paths if path.is_file()] + + +def generate_random_image(resolution: Tuple[int, int]) -> np.ndarray: + """ + Generate a random image tensor with the specified resolution. + + Parameters: + resolution (Tuple[int, int]): The height and width of the image. + + Returns: + np.ndarray: A random image array with shape [height, width, 3]. + """ + height, width = resolution + # Create a random RGB image (height, width, 3 channels) + random_image = np.random.randint(0, 256, (height, width, 3), dtype=np.uint8) + return random_image + + +def test_export(model_name: str): + # Get the model from the registry + model = ModelManager.get(model_name) + logger.info(f"Loaded model: {model_name}") + + # Get default resolution from model info + successful_exports = {} + + default_resolution = model.model_info.im_size + logger.info(f"Default resolution: {default_resolution}") + + # Test with default resolution + logger.info("Testing model with default resolution before export") + # Generate input as tensor for original model + input_tensor = torch.from_numpy( + np.random.randint(0, 256, (1, 3, default_resolution, default_resolution), dtype=np.uint8) + ).float() + + # Create numpy format for InferModel + input_image = generate_random_image((default_resolution, default_resolution)) + + try: + with torch.no_grad(): + model(input_tensor) + logger.info("Model successfully processed input before export") + successful_exports["original"] = {"export": True, "resolutions": {default_resolution: True}} + test_resolutions = [(224, 224), (320, 320), (480, 480), (640, 640), (512, 320)] + + for res in test_resolutions: + if res == (default_resolution, default_resolution): + continue + + logger.info(f"Testing model with resolution: {res}") + test_image = generate_random_image(res) + try: + model(test_image) + logger.info(f"Model successfully processed input with resolution {res}") + successful_exports["original"]["resolutions"][res] = True + except Exception as e: + logger.warning(f"Error processing input with resolution {res}: {e}") + successful_exports["original"]["resolutions"][res] = False + except Exception as e: + logger.error(f"Error processing input before export: {e}") + raise + + # Create a temporary directory for exporting + temp_dir = tempfile.mkdtemp() + export_path = os.path.join(temp_dir, f"{model_name}_exported") + logger.info(f"Created temporary directory for export: {export_path}") + + # Export the model + for runtime_type in [v for v in RuntimeType if v != RuntimeType.ONNX_CUDA32]: + try: + model.export(runtime_type=runtime_type, out_dir=export_path) + logger.info(f"Model successfully exported to: {export_path}") + files = list_files_with_extensions_recursively(export_path) + logger.info(f"Files in export directory: {[str(f) for f in files]}") + + # Expected files may vary based on export format, adjust as needed + expected_extensions = [".onnx", ".json", ".pt"] + for ext in expected_extensions: + if not any(str(f).endswith(ext) for f in files): + logger.warning(f"No file with extension {ext} found in export directory") + successful_exports[runtime_type] = {"export": True} + except Exception as e: + successful_exports[runtime_type] = {"export": False} + logger.warning(f"Error exporting model: {e}") + + # Verify export files + if successful_exports[runtime_type]["export"]: + # Load the exported model + exported_model = InferModel(export_path, model_info=model.model_info, runtime_type=runtime_type) + logger.info("Successfully loaded exported model") + successful_exports[runtime_type]["resolutions"] = {} + + # Test with default resolution on exported model + logger.info("Testing exported model with default resolution") + try: + exported_model(input_image) + logger.info("Exported model successfully processed input with default resolution") + successful_exports[runtime_type]["resolutions"][default_resolution] = True + except Exception as e: + logger.error(f"Error processing input with default resolution after export: {e}") + raise + + # Test with different resolutions + test_resolutions = [(224, 224), (320, 320), (480, 480), (640, 640), (512, 320)] + for res in test_resolutions: + if res == (default_resolution, default_resolution): + continue + + logger.info(f"Testing exported model with resolution: {res}") + test_image = generate_random_image(res) + try: + exported_model(test_image) + logger.info(f"Exported model successfully processed input with resolution {res}") + successful_exports[runtime_type]["resolutions"][res] = True + except Exception as e: + logger.warning(f"Error processing input with resolution {res}: {e}") + successful_exports[runtime_type]["resolutions"][res] = False + + latency = exported_model.benchmark() + logger.info(f"============ model {model_name} {runtime_type} =============") + logger.info(f"benchmark results: {latency}") + logger.info(f"===========================================================") + + for runtime_type in successful_exports: + if successful_exports[runtime_type]["export"]: + logger.info(f"โœ… EXPORT TEST DONE, model {model_name} successfully exported and tested.") + for res, success in successful_exports[runtime_type]["resolutions"].items(): + logger.info(f"\t Resolution {res}: {'โœ…' if success else 'โŒ'}") + else: + logger.info(f"โŒ EXPORT TEST FAILED, model {model_name} failed to export.") + return export_path + + +if __name__ == "__main__": + import argparse + + parser = argparse.ArgumentParser(description="Export a pretrained model and test it with various image resolutions") + parser.add_argument("--model", type=str, required=True, help="Name of the model to export and test") + + args = parser.parse_args() + logger.info(f"Exporting and testing model: {args.model}") + export_path = test_export(args.model) + logger.info(f"Model exported to: {export_path}") diff --git a/ops/test_training.py b/ops/test_training.py new file mode 100644 index 00000000..516a8904 --- /dev/null +++ b/ops/test_training.py @@ -0,0 +1,138 @@ +import os +import tempfile +from pathlib import Path +from typing import List, Optional, Union + +from focoos.data.auto_dataset import AutoDataset +from focoos.data.default_aug import get_default_by_task +from focoos.hub.api_client import ApiClient +from focoos.model_manager import ModelManager +from focoos.ports import DATASETS_DIR, DatasetLayout, DatasetSplitType, RuntimeType, Task, TrainerArgs +from focoos.utils.logger import get_logger + +logger = get_logger("TestTraning") + +datasets = [ + "chess-coco-detection.zip", + "fire-coco-instseg.zip", + "balloons-coco-sem.zip", +] + + +def list_files_with_extensions_recursively( + base_dir: Union[str, Path], extensions: Optional[List[str]] = None +) -> List[Path]: + """ + Generate a list of file paths with specific extensions recursively starting from a base directory. + + Parameters: + base_directory (Union[str, Path]): The directory to start the recursive search from. + extensions (Optional[List[str]]): List of file extensions to filter by. If None, all files will be included. + + Returns: + List[Path]: A list of Path objects representing the file paths that match the criteria. + """ + base_dir = Path(base_dir) + file_paths = [] + + if extensions: + for extension in extensions: + if extension.startswith("."): + extension = extension[1:] + _glob = f"*.{extension}" + file_paths.extend(base_dir.rglob(_glob)) + else: + file_paths.extend(base_dir.rglob("*")) + + return [path for path in file_paths if path.is_file()] + + +def get_dataset(task: Task): + if task == Task.SEMSEG: + ds_name = "balloons-coco-sem.zip" + layout = DatasetLayout.ROBOFLOW_SEG + + elif task == Task.DETECTION: + ds_name = "chess-coco-detection.zip" + layout = DatasetLayout.ROBOFLOW_COCO + elif task == Task.INSTANCE_SEGMENTATION: + ds_name = "fire-coco-instseg.zip" + layout = DatasetLayout.ROBOFLOW_COCO + else: + raise ValueError(f"Error: task {task} not supported") + url = f"https://public.focoos.ai/datasets/{ds_name}" + api_client = ApiClient() + api_client.download_ext_file(url, DATASETS_DIR, skip_if_exists=True) + return ds_name, layout + + +def train(model_name: str): + model = ModelManager.get(model_name) + + # Convert string task to Task enum + task = Task(model.model_info.task) + + dataset_name, layout = get_dataset(task) + + # Initialize dataset + auto_dataset = AutoDataset(dataset_name=dataset_name, task=task, layout=layout) + resolution = 640 + + # Get default augmentations for the specified task + train_augs, val_augs = get_default_by_task(task, resolution) + + train_augs.crop_size = resolution + train_augs.crop = True + + train_dataset = auto_dataset.get_split(augs=train_augs.get_augmentations(), split=DatasetSplitType.TRAIN) + valid_dataset = auto_dataset.get_split(augs=val_augs.get_augmentations(), split=DatasetSplitType.VAL) + + # Get again the model with the correct number of classes + model = ModelManager.get(model_name, num_classes=train_dataset.dataset.metadata.num_classes) + + _temp_dir = tempfile.mkdtemp() + # out_dir = os.path.join(_temp_dir, "output") + logger.info(f"Created temporary directory for training output: {_temp_dir}") + + # Configure training arguments + trainer_args = TrainerArgs( + run_name=model_name + "_test", + # output_dir=out_dir, + amp_enabled=True, + batch_size=8, + max_iters=50, + eval_period=50, + learning_rate=1e-4, + scheduler="MULTISTEP", + weight_decay=0.0, + workers=4, + ) + + # Start training + model.train(trainer_args, train_dataset, valid_dataset) + infer = model.export(runtime_type=RuntimeType.ONNX_CUDA32, overwrite=True) + infer.benchmark(iterations=50) + infer = model.export(runtime_type=RuntimeType.TORCHSCRIPT_32, overwrite=True) + infer.benchmark(iterations=50) + + out_dir = trainer_args.output_dir + files = list_files_with_extensions_recursively(out_dir) + files_to_check = ["log.txt", "model_final.pth", "model_info.json", "metrics.json", "model.onnx", "model.pt"] + for file in files_to_check: + assert any(os.path.basename(f) == file for f in files), f"File {file} not found in {out_dir}" + + print(f"โœ… {model_name} TEST DONE, {files_to_check} correctly found in {out_dir}. ======================") + + +if __name__ == "__main__": + import argparse + + import torch + + parser = argparse.ArgumentParser(description="Train a pretrained model") + parser.add_argument("--model", type=str, required=True, help="Name of the model to train") + + args = parser.parse_args() + logger.info(f"๐Ÿš€ Start training test: {args.model} =================================================") + torch.cuda.empty_cache() + train(args.model) diff --git a/ops/test_validation.py b/ops/test_validation.py new file mode 100644 index 00000000..26c9a41e --- /dev/null +++ b/ops/test_validation.py @@ -0,0 +1,81 @@ +import os +import tempfile + +from focoos.data.auto_dataset import AutoDataset +from focoos.data.default_aug import get_default_by_task +from focoos.model_manager import ModelManager +from focoos.ports import DATASETS_DIR, DatasetLayout, DatasetSplitType, Task, TrainerArgs +from focoos.utils.logger import get_logger + +logger = get_logger("TestValidation") + +THRESHOLD = 0.01 # 1% acceptable variation + + +def train(model_name: str): + model = ModelManager.get(model_name) + if model.model_info.val_metrics is None: + raise ValueError(f"Model {model_name} has no validation metrics. Please run training first.") + current_val_metrics = model.model_info.val_metrics.copy() + + # Convert string task to Task enum + task = Task(model.model_info.task) + + dataset_name, layout = model.model_info.val_dataset, DatasetLayout.CATALOG + assert dataset_name is not None, f"Dataset name is not set for model {model_name}" + + # Initialize dataset + try: + auto_dataset = AutoDataset(dataset_name=dataset_name, task=task, layout=layout, datasets_dir=DATASETS_DIR) + except Exception as e: + logger.error(f"Error initializing dataset: {e}. Check you have it downloaded and registered in the hub.") + raise + resolution = model.model_info.im_size + + # Get default augmentations for the specified task + train_augs, val_augs = get_default_by_task(task, resolution) + valid_dataset = auto_dataset.get_split(augs=val_augs.get_augmentations(), split=DatasetSplitType.VAL) + + _temp_dir = tempfile.mkdtemp() + out_dir = os.path.join(_temp_dir, "output") + logger.info(f"Created temporary directory for training output: {_temp_dir}") + + # Configure training arguments + trainer_args = TrainerArgs( + run_name=model_name + "_test", + output_dir=out_dir, + amp_enabled=True, + batch_size=8, + max_iters=100, + eval_period=50, + learning_rate=1e-4, + scheduler="MULTISTEP", + weight_decay=0.0, + workers=4, + ) + + # Start training + model.test(trainer_args, valid_dataset) + + original_metrics = dict(model.model_info.val_metrics.items()) + diff = {k: abs(v - current_val_metrics[k]) for k, v in original_metrics.items() if v != current_val_metrics[k]} + valid = True + for k, v in diff.items(): + if v > (THRESHOLD * original_metrics[k]): + logger.warning(f"{k}: {current_val_metrics[k]} -> {original_metrics[k]} ({v})") + valid = False + if valid: + logger.info(f"โœ… TEST DONE, Model {model_name} validated.") + else: + logger.warning(f"โŒ TEST FAILED, Model {model_name} didn't validate.") + + +if __name__ == "__main__": + import argparse + + parser = argparse.ArgumentParser(description="Train a pretrained model") + parser.add_argument("--model", type=str, required=True, help="Name of the model to validate") + + args = parser.parse_args() + logger.info(f"Training model: {args.model}") + train(args.model) diff --git a/pyproject.toml b/pyproject.toml index 6b8d8c2d..9216c94d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,6 @@ requires = ["setuptools", "wheel"] build-backend = "setuptools.build_meta" - [tool.ruff] line-length = 120 lint.select = ["E", "F"] @@ -23,28 +22,40 @@ convention = "google" [tool.setuptools.packages.find] include = ["focoos**"] +[tool.setuptools.package-data] +"focoos.model_registry" = ["*.json"] + [project] name = "focoos" -version = "0.14.1" +version = "0.15.0" description = "Focoos SDK" readme = "README.md" requires-python = ">=3.10" dependencies = [ "requests", "Pillow~=10.2.0", - "supervision~=0.25.1", - "opencv-python~=4.11.0", - "pydantic~=2.10.5", - "pydantic-settings~=2.7.1", + "supervision~=0.26.0rc7", + "opencv-python~=4.11.0.86", + "pydantic~=2.11.4", + "pydantic-settings~=2.8.1", "tqdm~=4.67.1", "numpy~=2.2.1", "scipy~=1.14.1", - "psutil~=6.1.1", - "setuptools~=75.7.0", - "matplotlib~=3.10.0", + "psutil~=7.0.0", + "matplotlib~=3.10.1", "colorama~=0.4.6", - "ipython" + "ipython", + "shapely~=2.1.0", + "fvcore~=0.1.4", + "pycocotools~=2.0.8", + "faster_coco_eval~=1.6.6", + "tensorboard~=2.19.0", + + "orjson~=3.10.18", + "gradio~=5.31.0", + "torch~=2.7.0", + "torchvision~=0.22.0", ] authors = [{ name = "focoos.ai", email = "info@focoos.ai" }] @@ -56,18 +67,17 @@ keywords = [ ] [project.optional-dependencies] -cpu = ["onnxruntime==1.20.1"] -cuda = ["onnxruntime-gpu==1.20.1"] -tensorrt = ["onnxruntime-gpu==1.20.1","tensorrt==10.5.0"] -torch = ["torch==2.3.0","torchvision"] +tensorrt = ["tensorrt==10.5.0"] +onnx = ["onnxruntime-gpu==1.22.0", "onnx>=1.17.0", "onnxslim~=0.1.54", "onnxscript~=0.2.7"] +onnx-cpu = ["onnxruntime==1.22.0","onnx>=1.18.0", "onnxslim~=0.1.54", "onnxscript~=0.2.7"] + dev = [ "pytest", "pytest-cov", "pytest-mock", "ruff", "python-dotenv", - "gradio~=5.10.0", - "pre-commit~=4.0.1", + "pre-commit~=4.2.0", "sniffio~=1.3.1", "ipykernel~=6.29.5", "tox", diff --git a/scripts/classification_dataset_example.py b/scripts/classification_dataset_example.py new file mode 100644 index 00000000..febb4798 --- /dev/null +++ b/scripts/classification_dataset_example.py @@ -0,0 +1,185 @@ +""" +Example of using the classification dataset and mapper. + +This script demonstrates how to: +1. Load a classification dataset from a folder structure +2. Apply augmentations using the ClassificationDatasetMapper +3. Visualize a few examples from the dataset + +Usage: + python examples/classification_dataset_example.py --data_dir /path/to/image_folder + +The data_dir should have the following structure: + /path/to/image_folder/ + โ”œโ”€โ”€ train/ + โ”‚ โ”œโ”€โ”€ class1/ + โ”‚ โ”‚ โ”œโ”€โ”€ img1.jpg + โ”‚ โ”‚ โ”œโ”€โ”€ img2.jpg + โ”‚ โ”œโ”€โ”€ class2/ + โ”‚ โ”‚ โ”œโ”€โ”€ img3.jpg + โ”‚ โ”‚ โ”œโ”€โ”€ img4.jpg + โ”œโ”€โ”€ val/ + โ”‚ โ”œโ”€โ”€ class1/ + โ”‚ โ”‚ โ”œโ”€โ”€ img5.jpg + โ”‚ โ”œโ”€โ”€ class2/ + โ”‚ โ”‚ โ”œโ”€โ”€ img6.jpg +""" + +import argparse +import random + +import matplotlib.pyplot as plt +import numpy as np +import torch +from torch.utils.data import DataLoader + +from focoos.data.datasets.dict_dataset import DictDataset +from focoos.data.mappers.classification_dataset_mapper import ClassificationDatasetMapper +from focoos.data.transforms import augmentation as A +from focoos.ports import DatasetSplitType + + +def parse_args(): + parser = argparse.ArgumentParser(description="Classification dataset example") + parser.add_argument("--data_dir", required=True, help="Path to the dataset directory") + parser.add_argument("--batch_size", type=int, default=4, help="Batch size") + parser.add_argument("--num_workers", type=int, default=2, help="Number of workers for data loading") + parser.add_argument("--visualize", action="store_true", help="Visualize samples") + return parser.parse_args() + + +def get_train_transforms(crop_size=224): + """Get standard training augmentations for classification""" + return [ + A.RandomBrightness(0.9, 1.1), + A.RandomContrast(0.9, 1.1), + A.RandomSaturation(0.9, 1.1), + A.RandomFlip(prob=0.5, horizontal=True, vertical=False), + A.ResizeShortestEdge( + short_edge_length=[crop_size, int(crop_size * 1.5)], + max_size=crop_size * 2, + sample_style="choice", + ), + A.RandomCrop(crop_type="absolute", crop_size=(crop_size, crop_size)), + ] + + +def get_val_transforms(crop_size=224): + """Get standard validation transforms for classification""" + return [ + A.ResizeShortestEdge( + short_edge_length=crop_size, + max_size=crop_size * 2, + ), + A.RandomCrop(crop_type="absolute", crop_size=(crop_size, crop_size)), + ] + + +def visualize_batch(batch, dataset): + """Visualize a batch of images with their labels""" + # Get class names from the dataset metadata + class_names = dataset.metadata.thing_classes + + # Create a figure with subplots + batch_size = len(batch) + fig, axes = plt.subplots(1, batch_size, figsize=(batch_size * 4, 4)) + + for i, data in enumerate(batch): + # Get the image and label + image = data.image + label = data.label + + # Convert tensor to numpy array and transpose from (C,H,W) to (H,W,C) + image_np = image.numpy().transpose(1, 2, 0) + + # Denormalize the image + mean = np.array([0.485, 0.456, 0.406]) + std = np.array([0.229, 0.224, 0.225]) + image_np = std * image_np + mean + image_np = np.clip(image_np, 0, 1) + + # Get the class name + class_name = class_names[label] if label is not None else "Unknown" + + # Plot the image + if batch_size == 1: + ax = axes + else: + ax = axes[i] + ax.imshow(image_np) + ax.set_title(f"Class: {class_name}") + ax.axis("off") + + plt.tight_layout() + plt.show() + + +def main(): + args = parse_args() + + # Set random seeds for reproducibility + random.seed(42) + np.random.seed(42) + torch.manual_seed(42) + + # Load the classification dataset + train_dataset = DictDataset.from_folder(args.data_dir, split=DatasetSplitType.TRAIN) + + val_dataset = DictDataset.from_folder(args.data_dir, split=DatasetSplitType.VAL) + + print(f"Loaded training dataset with {len(train_dataset)} images") + print(f"Loaded validation dataset with {len(val_dataset)} images") + print(f"Classes: {train_dataset.metadata.thing_classes}") + + # Create the dataset mappers with augmentations + train_mapper = ClassificationDatasetMapper( + is_train=True, + augmentations=get_train_transforms(), + ) + + val_mapper = ClassificationDatasetMapper( + is_train=False, + augmentations=get_val_transforms(), + ) + + # Function to apply the mapper to each dataset element + def map_dataset_element(dataset_dict): + return train_mapper(dataset_dict) + + def map_val_dataset_element(dataset_dict): + return val_mapper(dataset_dict) + + # Create data loaders + train_loader = DataLoader( + train_dataset, + batch_size=args.batch_size, + shuffle=True, + num_workers=args.num_workers, + collate_fn=lambda x: [map_dataset_element(x_i) for x_i in x], + ) + + val_loader = DataLoader( + val_dataset, + batch_size=args.batch_size, + shuffle=False, + num_workers=args.num_workers, + collate_fn=lambda x: [map_val_dataset_element(x_i) for x_i in x], + ) + + # Visualize a batch if requested + if args.visualize: + print("Visualizing a batch from the training set...") + for batch in train_loader: + visualize_batch(batch, train_dataset) + break + + print("Visualizing a batch from the validation set...") + for batch in val_loader: + visualize_batch(batch, val_dataset) + break + + print("Example usage complete!") + + +if __name__ == "__main__": + main() diff --git a/scripts/training.py b/scripts/training.py new file mode 100644 index 00000000..53f9d9f4 --- /dev/null +++ b/scripts/training.py @@ -0,0 +1,104 @@ +""" +Model Training Example + +This script demonstrates how to train a model using the FocoOS framework for various computer vision tasks. +It shows the complete workflow from dataset loading to model training with customizable parameters. + +Usage: + python examples/training.py --dataset_name aquarium --model_name fai-detr-m-coco --task detection --resolution 640 --batch_size 16 + +Parameters: + --dataset_name (str): Name of the dataset to use (default: "aquarium") + --task (str): Task type, one of "detection", "segmentation", "classification" (default: "detection") + --layout (str): Dataset layout format (default: "roboflow_coco") + --model_name (str): Name of the model to use (default: "fai-detr-m-coco") + --resolution (int): Input resolution for training (default: 640) + --batch_size (int): Batch size for training (default: 16) + --max_iters (int): Maximum number of training iterations (default: 100) + --learning_rate (float): Learning rate for training (default: 0.0001) + --output_dir (str): Directory to save training outputs (default: "./experiments") + --run_name (str): Name for this training run (default: "exp1") + --workers (int): Number of data loading workers (default: 16) + --advanced_aug (bool): Whether to use advanced augmentations (default: False) +""" + +import argparse + +from focoos.data.auto_dataset import AutoDataset +from focoos.data.default_aug import get_default_by_task +from focoos.model_manager import ModelManager +from focoos.ports import DatasetLayout, DatasetSplitType, Task, TrainerArgs + + +def parse_args(): + parser = argparse.ArgumentParser(description="Model Training Example") + parser.add_argument("--dataset_name", type=str, default="aquarium", help="Name of the dataset") + parser.add_argument( + "--task", + type=str, + default=Task.DETECTION.value, + choices=[task.value for task in Task], + help="Task type", + ) + parser.add_argument( + "--layout", + type=str, + default=DatasetLayout.ROBOFLOW_COCO.value, + choices=[layout.value for layout in DatasetLayout], + help="Dataset layout format", + ) + parser.add_argument("--model_name", type=str, default="fai-detr-m-coco", help="Model name") + parser.add_argument("--resolution", type=int, default=640, help="Input resolution") + parser.add_argument("--batch_size", type=int, default=16, help="Batch size for training") + parser.add_argument("--max_iters", type=int, default=100, help="Maximum training iterations") + parser.add_argument("--eval_period", type=int, default=100, help="Evaluation frequency") + parser.add_argument("--learning_rate", type=float, default=0.0001, help="Learning rate") + parser.add_argument("--weight_decay", type=float, default=0.0001, help="Weight decay") + parser.add_argument("--output_dir", type=str, default="./experiments", help="Output directory") + parser.add_argument("--run_name", type=str, default="exp1", help="Run name") + parser.add_argument("--workers", type=int, default=16, help="Number of workers") + parser.add_argument("--advanced_aug", action="store_true", help="Use advanced augmentations") + return parser.parse_args() + + +def main(): + args = parse_args() + + # Convert string task to Task enum + task = Task(args.task) + + # Convert string layout to DatasetLayout enum + layout = DatasetLayout(args.layout) + + # Initialize dataset + auto_dataset = AutoDataset(dataset_name=args.dataset_name, task=task, layout=layout) + resolution = args.resolution + + # Get default augmentations for the specified task + train_augs, val_augs = get_default_by_task(task, resolution, advanced=args.advanced_aug) + train_dataset = auto_dataset.get_split(augs=train_augs.get_augmentations(), split=DatasetSplitType.TRAIN) + valid_dataset = auto_dataset.get_split(augs=val_augs.get_augmentations(), split=DatasetSplitType.VAL) + + # Initialize model + model = ModelManager.get(args.model_name, num_classes=train_dataset.dataset.metadata.num_classes) + + # Configure training arguments + trainer_args = TrainerArgs( + run_name=args.run_name, + output_dir=args.output_dir, + amp_enabled=True, + batch_size=args.batch_size, + max_iters=args.max_iters, + eval_period=args.eval_period, + learning_rate=args.learning_rate, + scheduler="MULTISTEP", + weight_decay=args.weight_decay, + workers=args.workers, + ) + + # Start training + model.train(trainer_args, train_dataset, valid_dataset) + + +if __name__ == "__main__": + main() diff --git a/scripts/validation.py b/scripts/validation.py new file mode 100644 index 00000000..310d950f --- /dev/null +++ b/scripts/validation.py @@ -0,0 +1,94 @@ +""" +Model Validation Example + +This script demonstrates how to validate a model using the FocoOS framework for various computer vision tasks. +It shows the workflow for evaluating a model on a validation dataset with customizable parameters. + +Usage: + python examples/validation.py --dataset_name coco_2017_instance --model_name fai-mf-s-coco-ins --task instance_segmentation --resolution 1024 --batch_size 16 + +Parameters: + --dataset_name (str): Name of the dataset to use (default: "coco_2017_instance") + --task (str): Task type, one of "detection", "segmentation", "instance_segmentation", "classification" (default: "instance_segmentation") + --layout (str): Dataset layout format (default: "catalog") + --model_name (str): Name of the model to use (default: "fai-mf-s-coco-ins") + --resolution (int): Input resolution for validation (default: 1024) + --batch_size (int): Batch size for validation (default: 16) + --output_dir (str): Directory to save validation outputs (default: "./experiments") + --run_name (str): Name for this validation run (default: None - uses model name) + --workers (int): Number of data loading workers (default: 16) +""" + +import argparse + +from focoos.data.auto_dataset import AutoDataset +from focoos.data.default_aug import get_default_by_task +from focoos.model_manager import ModelManager +from focoos.ports import DatasetLayout, DatasetSplitType, Task, TrainerArgs + + +def parse_args(): + parser = argparse.ArgumentParser(description="Model Validation Example") + parser.add_argument("--dataset_name", type=str, default="coco_2017_instance", help="Name of the dataset") + parser.add_argument( + "--task", + type=str, + default=Task.INSTANCE_SEGMENTATION.value, + choices=[task.value for task in Task], + help="Task type", + ) + parser.add_argument( + "--layout", + type=str, + default=DatasetLayout.CATALOG.value, + choices=[layout.value for layout in DatasetLayout], + help="Dataset layout format", + ) + parser.add_argument("--model_name", type=str, default="fai-mf-s-coco-ins", help="Model name") + parser.add_argument("--resolution", type=int, default=1024, help="Input resolution") + parser.add_argument("--batch_size", type=int, default=16, help="Batch size for validation") + parser.add_argument("--output_dir", type=str, default="./experiments", help="Output directory") + parser.add_argument("--run_name", type=str, default=None, help="Run name (defaults to model name if None)") + parser.add_argument("--workers", type=int, default=16, help="Number of workers") + parser.add_argument("--advanced_aug", action="store_true", help="Use advanced augmentations") + return parser.parse_args() + + +def main(): + args = parse_args() + + # Convert string task to Task enum + task = Task(args.task) + + # Convert string layout to DatasetLayout enum + layout = DatasetLayout(args.layout) + + # Initialize dataset + auto_dataset = AutoDataset(dataset_name=args.dataset_name, task=task, layout=layout) + resolution = args.resolution + + # Get default augmentations for the specified task + train_augs, val_augs = get_default_by_task(task, resolution, advanced=args.advanced_aug) + valid_dataset = auto_dataset.get_split(augs=val_augs.get_augmentations(), split=DatasetSplitType.VAL) + + # Initialize model + model = ModelManager.get(args.model_name) + + # Use model name as run name if not specified + run_name = args.run_name if args.run_name is not None else model.model_info.name + + # Configure validation arguments + trainer_args = TrainerArgs( + run_name=run_name, + output_dir=args.output_dir, + amp_enabled=True, + batch_size=args.batch_size, + workers=args.workers, + ) + + # Start validation + model.test(trainer_args, valid_dataset) + + +if __name__ == "__main__": + main() diff --git a/tests/conftest.py b/tests/conftest.py index 4c3abab4..810692b9 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -7,13 +7,13 @@ import pytest from PIL import Image -from focoos.ports import FocoosTask, ModelMetadata, ModelStatus +from focoos.ports import ModelStatus, RemoteModelInfo, Task @pytest.fixture def mock_api_client(): """Fixture to create a mock ApiClient.""" - with patch("focoos.focoos.ApiClient") as MockApiClient: + with patch("focoos.hub.api_client.ApiClient") as MockApiClient: mock_client = MockApiClient.return_value yield mock_client @@ -49,22 +49,21 @@ def image_ndarray(pil_image: Image.Image) -> np.ndarray: @pytest.fixture def mock_metadata(): - return ModelMetadata( + return RemoteModelInfo( ref="test_model_ref", name="Test Model", description="A test model for unit tests", owner_ref="test_owner", focoos_model="test_focoos_model", - task=FocoosTask.DETECTION, + task=Task.DETECTION, + is_managed=False, created_at=datetime.datetime.now(), updated_at=datetime.datetime.now(), status=ModelStatus.DEPLOYED, metrics={"accuracy": 0.95}, - latencies=[{"inference": 0.1}], classes=["class_0", "class_1"], im_size=640, hyperparameters=None, training_info=None, - location=None, dataset=None, ) diff --git a/tests/test_api_client.py b/tests/test_api_client.py new file mode 100644 index 00000000..5609d419 --- /dev/null +++ b/tests/test_api_client.py @@ -0,0 +1,302 @@ +import os +import tempfile +from unittest.mock import MagicMock, patch + +import pytest + +from focoos.hub.api_client import ApiClient + + +@pytest.fixture +def extra_headers(): + """Fixture to provide extra headers for testing.""" + return {"Custom-Header": "custom-value"} + + +@pytest.fixture +def api_client(): + """Fixture to provide an ApiClient instance for testing.""" + return ApiClient(api_key="test_key", host_url="http://example.com") + + +def test_api_client_initialization(): + client = ApiClient(api_key="test_key", host_url="http://example.com") + assert client.api_key == "test_key" + assert client.host_url == "http://example.com" + assert "X-API-Key" in client.default_headers + assert client.default_headers["X-API-Key"] == "test_key" + + +def test_api_client_initialization_with_defaults(): + with patch("focoos.hub.api_client.FOCOOS_CONFIG") as mock_config: + mock_config.focoos_api_key = "default_key" + mock_config.default_host_url = "http://default.com" + client = ApiClient() + assert client.api_key == "default_key" + assert client.host_url == "http://default.com" + + +def test_api_client_check_api_key_missing(): + client = ApiClient(api_key="", host_url="http://example.com") + with pytest.raises(ValueError, match="API key is required"): + client.get("test/path") + + +def test_api_client_check_api_key_whitespace(): + client = ApiClient(api_key=" ", host_url="http://example.com") + with pytest.raises(ValueError, match="API key is required"): + client.get("test/path") + + +def test_api_client_check_api_key_none(): + with patch("focoos.hub.api_client.FOCOOS_CONFIG") as mock_config: + mock_config.focoos_api_key = None + mock_config.default_host_url = "http://example.com" + client = ApiClient(api_key=None, host_url="http://example.com") + with pytest.raises(ValueError, match="API key is required"): + client.get("test/path") + + +def test_api_client_get_external_url(): + client = ApiClient(api_key="test_key", host_url="http://example.com") + with patch("requests.get") as mock_get: + mock_get.return_value.status_code = 200 + response = client.external_get("test/path") + assert response.status_code == 200 + mock_get.assert_called_with("test/path", params={}, stream=False) + + +def test_api_client_get(extra_headers): + client = ApiClient(api_key="test_key", host_url="http://example.com") + with patch("requests.get") as mock_get: + mock_get.return_value.status_code = 200 + response = client.get("test/path", extra_headers=extra_headers) + assert response.status_code == 200 + mock_get.assert_called_with( + "http://example.com/test/path", + headers={**client.default_headers, **extra_headers}, + params=None, + stream=False, + ) + + +def test_api_client_get_with_params(api_client): + with patch("requests.get") as mock_get: + mock_get.return_value.status_code = 200 + params = {"param1": "value1", "param2": "value2"} + response = api_client.get("test/path", params=params) + assert response.status_code == 200 + mock_get.assert_called_with( + "http://example.com/test/path", + headers=api_client.default_headers, + params=params, + stream=False, + ) + + +def test_api_client_post(extra_headers): + client = ApiClient(api_key="test_key", host_url="http://example.com") + with patch("requests.post") as mock_post: + mock_post.return_value.status_code = 201 + response = client.post("test/path", data={"key": "value"}, extra_headers=extra_headers) + assert response.status_code == 201 + mock_post.assert_called_with( + "http://example.com/test/path", + headers={**client.default_headers, **extra_headers}, + json={"key": "value"}, + files=None, + ) + + +def test_api_client_post_with_files(api_client): + with patch("requests.post") as mock_post: + mock_post.return_value.status_code = 201 + files = {"file": ("test.txt", "content")} + response = api_client.post("test/path", files=files) + assert response.status_code == 201 + mock_post.assert_called_with( + "http://example.com/test/path", + headers=api_client.default_headers, + json=None, + files=files, + ) + + +def test_api_client_patch(api_client): + with patch("requests.patch") as mock_patch: + mock_patch.return_value.status_code = 200 + response = api_client.patch("test/path", data={"key": "value"}) + assert response.status_code == 200 + mock_patch.assert_called_with( + "http://example.com/test/path", + headers=api_client.default_headers, + json={"key": "value"}, + ) + + +def test_api_client_patch_with_headers(api_client): + extra_headers = {"Authorization": "Bearer token"} + with patch("requests.patch") as mock_patch: + mock_patch.return_value.status_code = 200 + response = api_client.patch("test/path", data={"key": "value"}, extra_headers=extra_headers) + assert response.status_code == 200 + mock_patch.assert_called_with( + "http://example.com/test/path", + headers={**api_client.default_headers, **extra_headers}, + json={"key": "value"}, + ) + + +def test_api_client_external_post(): + client = ApiClient(api_key="test_key", host_url="http://example.com") + with patch("requests.post") as mock_post: + mock_post.return_value.status_code = 201 + response = client.external_post("http://external.com/upload", data={"key": "value"}) + assert response.status_code == 201 + mock_post.assert_called_with( + "http://external.com/upload", + headers={}, + json={"key": "value"}, + files=None, + stream=False, + ) + + +def test_api_client_external_post_with_headers(): + client = ApiClient(api_key="test_key", host_url="http://example.com") + extra_headers = {"Authorization": "Bearer token"} + with patch("requests.post") as mock_post: + mock_post.return_value.status_code = 201 + response = client.external_post("http://external.com/upload", extra_headers=extra_headers) + assert response.status_code == 201 + mock_post.assert_called_with( + "http://external.com/upload", + headers=extra_headers, + json=None, + files=None, + stream=False, + ) + + +def test_api_client_delete(extra_headers): + client = ApiClient(api_key="test_key", host_url="http://example.com") + with patch("requests.delete") as mock_delete: + mock_delete.return_value.status_code = 204 + response = client.delete("test/path", extra_headers=extra_headers) + assert response.status_code == 204 + mock_delete.assert_called_with( + "http://example.com/test/path", + headers={**client.default_headers, **extra_headers}, + ) + + +def test_api_client_upload_file(api_client): + with patch("requests.post") as mock_post: + mock_post.return_value.status_code = 200 + response = api_client.upload_file("upload/file", "/path/to/file.txt", 1024) + assert response.status_code == 200 + # upload_file calls the post method internally + mock_post.assert_called_once() + + +def test_api_client_download_ext_file_success(api_client): + with tempfile.TemporaryDirectory() as temp_dir: + with patch("requests.get") as mock_get: + # Mock successful response + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.headers = {"content-length": "1024"} + mock_response.iter_content.return_value = [b"test content"] + mock_get.return_value = mock_response + + # Test download + file_path = api_client.download_ext_file("http://example.com/file.txt", temp_dir, file_name="test_file.txt") + + assert file_path == os.path.join(temp_dir, "test_file.txt") + assert os.path.exists(file_path) + with open(file_path, "r") as f: + content = f.read() + assert content == "test content" + + +def test_api_client_download_ext_file_failure(api_client): + with tempfile.TemporaryDirectory() as temp_dir: + with patch("requests.get") as mock_get: + # Mock failed response + mock_response = MagicMock() + mock_response.status_code = 404 + mock_response.text = "Not Found" + mock_get.return_value = mock_response + + # Test download failure + with pytest.raises(ValueError, match="Failed to download file"): + api_client.download_ext_file("http://example.com/nonexistent.txt", temp_dir) + + +def test_api_client_download_ext_file_skip_if_exists(api_client): + with tempfile.TemporaryDirectory() as temp_dir: + # Create existing file + existing_file = os.path.join(temp_dir, "existing.txt") + with open(existing_file, "w") as f: + f.write("existing content") + + with patch("requests.get") as mock_get: + # Mock successful response (even though file exists, HTTP request is still made) + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.headers = {"content-length": "1024"} + mock_response.iter_content.return_value = [b"new content"] + mock_get.return_value = mock_response + + # Test skip if exists + file_path = api_client.download_ext_file( + "http://example.com/existing.txt", temp_dir, file_name="existing.txt", skip_if_exists=True + ) + + assert file_path == existing_file + # HTTP request is made, but file is not overwritten due to skip_if_exists + mock_get.assert_called_once() + # Verify original content is preserved + with open(existing_file, "r") as f: + content = f.read() + assert content == "existing content" + + +def test_api_client_download_ext_file_invalid_directory(api_client): + with tempfile.NamedTemporaryFile() as temp_file: + # Try to use a file as directory + with pytest.raises(ValueError, match="Path is not a directory"): + api_client.download_ext_file("http://example.com/file.txt", temp_file.name) + + +def test_api_client_download_ext_file_creates_directory(api_client): + with tempfile.TemporaryDirectory() as temp_dir: + # Create path to non-existing subdirectory + non_existing_dir = os.path.join(temp_dir, "new_subdir", "nested_dir") + + with patch("requests.get") as mock_get: + # Mock successful response + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.headers = {"content-length": "1024"} + mock_response.iter_content.return_value = [b"test content"] + mock_get.return_value = mock_response + + # Test download to non-existing directory + file_path = api_client.download_ext_file( + "http://example.com/file.txt", non_existing_dir, file_name="test_file.txt" + ) + + # Verify directory was created + assert os.path.exists(non_existing_dir) + assert os.path.isdir(non_existing_dir) + + # Verify file was downloaded to the created directory + expected_file_path = os.path.join(non_existing_dir, "test_file.txt") + assert file_path == expected_file_path + assert os.path.exists(file_path) + + # Verify file content + with open(file_path, "r") as f: + content = f.read() + assert content == "test content" diff --git a/tests/test_backbone.py b/tests/test_backbone.py new file mode 100644 index 00000000..528db5fc --- /dev/null +++ b/tests/test_backbone.py @@ -0,0 +1,137 @@ +import pytest +import torch + +from focoos.model_manager import ConfigBackboneManager +from focoos.nn.backbone.base import BackboneConfig +from focoos.nn.backbone.build import load_backbone + +# List of all backbone types with their minimum required config +BACKBONE_CONFIGS = { + "resnet": {"model_type": "resnet", "use_pretrained": False, "depth": 18}, + "stdc": {"model_type": "stdc", "use_pretrained": False, "base": 64, "layers": [4, 5, 3]}, + "swin": {"model_type": "swin", "use_pretrained": False}, + "mobilenet_v2": {"model_type": "mobilenet_v2", "use_pretrained": False}, + "convnextv2": {"model_type": "convnextv2", "use_pretrained": False}, +} + +# Different input sizes to test +INPUT_SIZES = [ + (1, 3, 224, 224), # Standard size + (1, 3, 384, 384), # Larger size + (2, 3, 224, 224), # Batch size 2 +] + + +def test_build_function(): + """Test the backbone build function.""" + for backbone_type, config_dict in BACKBONE_CONFIGS.items(): + config = ConfigBackboneManager.from_dict(config_dict) + + # Test that the backbone can be built + backbone = load_backbone(config) + assert backbone is not None, f"Failed to build backbone of type {backbone_type}" + + +@pytest.mark.parametrize("backbone_type", BACKBONE_CONFIGS.keys()) +def test_backbone_initialization(backbone_type): + """Test that each backbone can be initialized.""" + config_dict = BACKBONE_CONFIGS[backbone_type] + config = ConfigBackboneManager.from_dict(config_dict) + + # Initialize the backbone + backbone = load_backbone(config) + + assert backbone is not None, f"Failed to initialize backbone of type {backbone_type}" + assert isinstance(backbone.config, BackboneConfig), "Backbone config should be an instance of BackboneConfig" + + +@pytest.mark.parametrize("backbone_type", BACKBONE_CONFIGS.keys()) +@pytest.mark.parametrize("input_size", INPUT_SIZES) +def test_backbone_forward(backbone_type, input_size): + """Test that each backbone can process a forward pass with different input sizes.""" + config_dict = BACKBONE_CONFIGS[backbone_type] + config = ConfigBackboneManager.from_dict(config_dict) + + # Initialize the backbone + backbone = load_backbone(config) + + # Create a random input tensor + x = torch.rand(*input_size) + + # Switch to eval mode to avoid batch norm issues + backbone.eval() + + # Forward pass + with torch.no_grad(): + outputs = backbone(x) + + # Check that outputs is a dictionary + assert isinstance(outputs, dict), f"Backbone {backbone_type} output should be a dictionary" + + # Check that each output is a tensor + for name, tensor in outputs.items(): + assert isinstance(tensor, torch.Tensor), f"Output {name} should be a tensor" + + # Check that the batch dimension is preserved + assert tensor.shape[0] == input_size[0], f"Output {name} should have batch size {input_size[0]}" + + +def test_output_shapes(): + """Test that the output_shape method returns the expected shapes.""" + for backbone_type, config_dict in BACKBONE_CONFIGS.items(): + config = ConfigBackboneManager.from_dict(config_dict) + + # Initialize the backbone + backbone = load_backbone(config) + + # Get output shapes + shapes = backbone.output_shape() + + # Check that shapes is a dictionary + assert isinstance(shapes, dict), f"output_shape for {backbone_type} should return a dictionary" + + # Check each shape specification + for name, shape_spec in shapes.items(): + assert hasattr(shape_spec, "channels"), f"Shape spec for {name} should have channels attribute" + assert hasattr(shape_spec, "stride"), f"Shape spec for {name} should have stride attribute" + + +def test_invalid_backbone_type(): + """Test that trying to build a backbone with an invalid type raises a ValueError.""" + config_dict = {"model_type": "invalid_backbone", "use_pretrained": False} + + with pytest.raises(ValueError, match="Backbone invalid_backbone not supported"): + ConfigBackboneManager.from_dict(config_dict) + + +def test_size_divisibility(): + """Test that size_divisibility property works.""" + for backbone_type, config_dict in BACKBONE_CONFIGS.items(): + config = ConfigBackboneManager.from_dict(config_dict) + backbone = load_backbone(config) + + # Should be an integer + assert isinstance(backbone.size_divisibility, int) + + +def test_padding_constraints(): + """Test that padding_constraints property works.""" + for backbone_type, config_dict in BACKBONE_CONFIGS.items(): + config = ConfigBackboneManager.from_dict(config_dict) + backbone = load_backbone(config) + + # Should return a dict + constraints = backbone.padding_constraints + assert isinstance(constraints, dict) + + +if __name__ == "__main__": + test_build_function() + for backbone_type in BACKBONE_CONFIGS.keys(): + test_backbone_initialization(backbone_type) + for input_size in INPUT_SIZES: + test_backbone_forward(backbone_type, input_size) + test_output_shapes() + test_invalid_backbone_type() + test_size_divisibility() + test_padding_constraints() diff --git a/tests/test_focoos.py b/tests/test_focoos.py deleted file mode 100644 index e1464710..00000000 --- a/tests/test_focoos.py +++ /dev/null @@ -1,466 +0,0 @@ -import pathlib -import tempfile -from datetime import datetime -from unittest.mock import MagicMock - -import pytest -from pytest_mock import MockerFixture - -from focoos import Focoos -from focoos.config import FOCOOS_CONFIG -from focoos.local_model import LocalModel -from focoos.ports import ModelNotFound, ModelPreview -from focoos.remote_model import RemoteModel - - -@pytest.fixture -def focoos_instance(mock_api_client) -> Focoos: - """Fixture to provide a Focoos instance with a mocked ApiClient.""" - mock_api_client.get.return_value.status_code = 200 - mock_api_client.get.return_value.json.return_value = { - "email": "test@example.com", - "created_at": "2024-01-01", - "updated_at": "2025-01-01", - "company": "test_company", - "api_key": {"key": "test_api_key"}, - "quotas": { - "total_inferences": 10, - "max_inferences": 1000, - "used_storage_gb": 10, - "max_storage_gb": 1000, - "active_training_jobs": ["job1"], - "max_active_training_jobs": 1, - "used_mlg4dnxlarge_training_jobs_hours": 10, - "max_mlg4dnxlarge_training_jobs_hours": 1000, - }, - } - return Focoos(api_key="test_api_key", host_url="http://mock-host-url.com") - - -@pytest.fixture -def mock_shared_datasets(): - return [ - { - "ref": "79742c8814f94fcd", - "url": "s3://mock-s3-url", - "name": "Aeroscapes", - "layout": "supervisely", - "description": "Mock descr 1", - "task": "semseg", - }, - { - "ref": "cce71b2050be4e28", - "url": "s3://mock-s3-url", - "name": "Blister", - "layout": "roboflow_coco", - "description": "Mock descr 2", - "task": "instseg", - }, - ] - - -@pytest.fixture -def mock_list_models(): - return [ - { - "name": "model1", - "ref": "ref1", - "task": "detection", - "description": "model1 description", - "status": "TRAINING_COMPLETED", - "focoos_model": "focoos_rtdetr", - }, - { - "name": "model2", - "ref": "ref2", - "task": "detection", - "description": "model2 description", - "status": "TRAINING_RUNNING", - "focoos_model": "focoos_rtdetr", - }, - ] - - -@pytest.fixture -def mock_list_models_as_base_models(mock_list_models): - return [ModelPreview.from_json(r) for r in mock_list_models] - - -@pytest.fixture -def mock_remote_model(): - return MagicMock(spec=RemoteModel, model_ref="ref1") - - -@pytest.fixture -def mock_local_model(): - return MagicMock(spec=LocalModel, name="model1", model_ref="ref1") - - -def test_focoos_initialization_no_api_key(focoos_instance: Focoos): - focoos_instance.api_client.get = MagicMock( - return_value=MagicMock(status_code=200, json=lambda: {"email": "test@example.com"}) - ) - FOCOOS_CONFIG.focoos_api_key = "" - with pytest.raises(ValueError): - Focoos(host_url="http://mock-host-url.com") - - -def test_focoos_initialization_fail_to_fetch_user_info(focoos_instance: Focoos): - focoos_instance.api_client.get = MagicMock(return_value=MagicMock(status_code=500)) - with pytest.raises(ValueError): - Focoos(api_key="test_api_key") - - -def test_focoos_initialization(focoos_instance: Focoos): - assert focoos_instance.api_key == "test_api_key" - assert focoos_instance.user_info.email == "test@example.com" - assert focoos_instance.user_info.company == "test_company" - assert focoos_instance.user_info.created_at == datetime(2024, 1, 1) - assert focoos_instance.user_info.updated_at == datetime(2025, 1, 1) - assert focoos_instance.user_info.quotas.total_inferences == 10 - assert focoos_instance.user_info.quotas.max_inferences == 1000 - assert focoos_instance.user_info.quotas.used_storage_gb == 10 - assert focoos_instance.user_info.quotas.max_storage_gb == 1000 - assert focoos_instance.user_info.quotas.active_training_jobs == ["job1"] - assert focoos_instance.user_info.quotas.max_active_training_jobs == 1 - assert focoos_instance.user_info.quotas.used_mlg4dnxlarge_training_jobs_hours == 10 - assert focoos_instance.user_info.quotas.max_mlg4dnxlarge_training_jobs_hours == 1000 - - -def test_get_model_info(focoos_instance: Focoos): - mock_response = { - "name": "test-model", - "ref": "model-ref", - "owner_ref": "pytest", - "focoos_model": "focoos_rtdetr", - "created_at": "2024-01-01", - "updated_at": "2024-01-01", - "description": "Test model description", - "task": "detection", - "status": "TRAINING_COMPLETED", - } - focoos_instance.api_client.get = MagicMock(return_value=MagicMock(status_code=200, json=lambda: mock_response)) - model_info = focoos_instance.get_model_info("test-model") - assert model_info.name == "test-model" - assert model_info.ref == "model-ref" - assert model_info.description == "Test model description" - - -def test_get_model_info_fail(focoos_instance: Focoos): - focoos_instance.api_client.get = MagicMock(return_value=MagicMock(status_code=500)) - - with pytest.raises(ValueError): - focoos_instance.get_model_info("test-model") - - -def test_list_models(focoos_instance: Focoos, mock_list_models): - focoos_instance.api_client.get = MagicMock(return_value=MagicMock(status_code=200, json=lambda: mock_list_models)) - - models = focoos_instance.list_models() - assert len(models) == 2 - assert models[0].name == "model1" - assert models[1].ref == "ref2" - - -def test_list_models_fail(focoos_instance: Focoos): - focoos_instance.api_client.get = MagicMock(return_value=MagicMock(status_code=500)) - - with pytest.raises(ValueError): - focoos_instance.list_models() - - -def test_list_focoos_models(focoos_instance: Focoos): - mock_response = [ - { - "ref": "mock_model_1_ref", - "name": "mock_model_1_name", - "task": "detection", - "description": "An advanced model that classifies images with high accuracy!", - "status": "DEPLOYED", - "focoos_model": "mock_model_base.v1.presnet34", - }, - { - "ref": "mock_model_2_ref", - "name": "mock_model_2_name", - "task": "semseg", - "description": "Segmentation model that detects boundaries with precision.", - "status": "DEPLOYED", - "focoos_model": "mock_model_base.v2.densenet121", - }, - ] - - focoos_instance.api_client.get = MagicMock(return_value=MagicMock(status_code=200, json=lambda: mock_response)) - - models = focoos_instance.list_focoos_models() - assert len(models) == 2 - assert models[0].name == "mock_model_1_name" - assert models[1].ref == "mock_model_2_ref" - - -def test_list_focoos_models_fail(focoos_instance: Focoos): - focoos_instance.api_client.get = MagicMock(return_value=MagicMock(status_code=500)) - - with pytest.raises(ValueError): - focoos_instance.list_focoos_models() - - -def test_list_shared_datasets(focoos_instance: Focoos, mock_shared_datasets): - focoos_instance.api_client.get = MagicMock( - return_value=MagicMock(status_code=200, json=lambda: mock_shared_datasets) - ) - - res = focoos_instance.list_shared_datasets() - - assert len(res) == 2 - assert res[0].name == "Aeroscapes" - assert res[1].ref == "cce71b2050be4e28" - - -def test_list_shared_datasets_fail(focoos_instance: Focoos): - focoos_instance.api_client.get = MagicMock(return_value=MagicMock(status_code=500)) - with pytest.raises(ValueError): - focoos_instance.list_shared_datasets() - - -""" -unit tests get_model_by_name -""" - - -@pytest.mark.parametrize("model_name", ["model1", "Model1"]) -def test_get_model_by_name_remote( - focoos_instance: Focoos, - mock_list_models_as_base_models, - mock_remote_model, - model_name, -): - focoos_instance.list_models = MagicMock(return_value=mock_list_models_as_base_models) - focoos_instance.get_remote_model = MagicMock(return_value=mock_remote_model) - - model = focoos_instance.get_model_by_name(name=model_name, remote=True) - assert model is not None - assert model.model_ref == "ref1" - assert isinstance(model, RemoteModel) - - -@pytest.mark.parametrize("model_name", ["model1", "Model1"]) -def test_get_model_by_name_local( - focoos_instance: Focoos, - mock_list_models_as_base_models, - mock_local_model, - model_name, -): - focoos_instance.list_models = MagicMock(return_value=mock_list_models_as_base_models) - focoos_instance.get_local_model = MagicMock(return_value=mock_local_model) - model = focoos_instance.get_model_by_name(name=model_name, remote=False) - assert model is not None - assert model.model_ref == "ref1" - assert isinstance(model, LocalModel) - - -def test_get_model_by_name_model_not_found(focoos_instance: Focoos, mock_list_models): - focoos_instance.api_client.get = MagicMock(return_value=MagicMock(status_code=200, json=lambda: mock_list_models)) - with pytest.raises(ModelNotFound): - focoos_instance.get_model_by_name(name="model3") - - -def test_get_remote_model(mocker: MockerFixture, focoos_instance: Focoos, mock_remote_model, mock_api_client): - mock_remote_model_class = mocker.patch("focoos.focoos.RemoteModel", autospec=True) - mock_remote_model_class.return_value = mock_remote_model - model_ref = "ref1" - model = focoos_instance.get_remote_model(model_ref) - assert model is not None - assert model.model_ref == model_ref - mock_remote_model_class.assert_called_once_with(model_ref, mock_api_client) - assert isinstance(model, RemoteModel) - - -def test_get_local_model(mocker: MockerFixture, focoos_instance: Focoos, mock_local_model): - # Mock the LocalModel class - mock_local_model_class = mocker.patch("focoos.focoos.LocalModel", autospec=True) - mock_local_model_class.return_value = mock_local_model - - # Spy on the _download_model method - download_model_spy = mocker.spy(focoos_instance, "_download_model") - - with tempfile.TemporaryDirectory() as temp_dir: - focoos_instance.cache_dir = temp_dir - # Setup test data - model_ref = "ref1" - model_path = pathlib.Path(focoos_instance.cache_dir) / model_ref / "model.onnx" - model_path.mkdir(parents=True, exist_ok=True) - - # Call the method under test - model = focoos_instance.get_local_model(model_ref) - - # Assertions - assert model is not None - assert model.model_ref == model_ref - mock_local_model_class.assert_called_once_with(str(model_path.parent), FOCOOS_CONFIG.runtime_type) - assert isinstance(model, LocalModel) - - # Assert _download_model was not called - download_model_spy.assert_not_called() - - -def test_get_local_model_with_download(mocker: MockerFixture, focoos_instance: Focoos, mock_local_model): - # Mock the LocalModel class - mock_local_model_class = mocker.patch("focoos.focoos.LocalModel", autospec=True) - mock_local_model_class.return_value = mock_local_model - - # Spy on the _download_model method - mock_download_model = mocker.patch.object(focoos_instance, "_download_model", autospec=True) - - with tempfile.TemporaryDirectory() as temp_dir: - focoos_instance.cache_dir = temp_dir - # Setup test data - model_ref = "ref1" - model_path = pathlib.Path(focoos_instance.cache_dir) / model_ref - model_path.mkdir(parents=True, exist_ok=True) - model_path = model_path / "model.onnx" - - # Call the method under test - model = focoos_instance.get_local_model(model_ref) - - # Assertions - assert model is not None - assert model.model_ref == model_ref - mock_local_model_class.assert_called_once_with(str(model_path.parent), FOCOOS_CONFIG.runtime_type) - assert isinstance(model, LocalModel) - - # Assert _download_model was not called - mock_download_model.assert_called() - - -def test_new_model_created( - mocker: MockerFixture, - focoos_instance: Focoos, - mock_remote_model: RemoteModel, - mock_api_client, -): - focoos_instance.api_client.post = MagicMock( - return_value=MagicMock( - status_code=201, - json=lambda: { - "ref": mock_remote_model.model_ref, - }, - ) - ) - mock_remote_model_class = mocker.patch("focoos.focoos.RemoteModel", autospec=True) - mock_remote_model_class.return_value = mock_remote_model - - model = focoos_instance.new_model("fakename", "fakefocoosmodel", "fakedescription") - - assert model is not None - mock_remote_model_class.assert_called_once_with(mock_remote_model.model_ref, mock_api_client) - assert isinstance(model, RemoteModel) - - -def test_new_model_already_exists(mocker: MockerFixture, focoos_instance: Focoos, mock_remote_model: RemoteModel): - model_name = "fakename" - focoos_instance.api_client.post = MagicMock(return_value=MagicMock(status_code=409)) - mock_get_model_by_name = mocker.patch.object(focoos_instance, "get_model_by_name", autospec=True) - mock_get_model_by_name.return_value = mock_remote_model - - model = focoos_instance.new_model(model_name, "fakefocoosmodel", "fakedescription") - assert model is not None - mock_get_model_by_name.assert_called_once_with(model_name, remote=True) - assert isinstance(model, RemoteModel) - - -def test_new_model_fail(focoos_instance: Focoos): - model_name = "fakename" - focoos_instance.api_client.post = MagicMock(return_value=MagicMock(status_code=500)) - model = focoos_instance.new_model(model_name, "fakefocoosmodel", "fakedescription") - assert model is None - - -def test_download_model_already_exists(focoos_instance: Focoos): - model_ref = "ref1" - with tempfile.TemporaryDirectory() as model_dir_tmp: - focoos_instance.cache_dir = model_dir_tmp - model_dir_tmp = pathlib.Path(model_dir_tmp) / model_ref - model_dir_tmp.mkdir(parents=True, exist_ok=True) - model_onnx_path = model_dir_tmp / "model.onnx" - model_onnx_path.touch() - (model_dir_tmp / "focoos_metadata.json").touch() - model_path = focoos_instance._download_model(model_ref) - assert model_path is not None - assert model_path == str(model_dir_tmp / "model.onnx") - - -def test_download_model_onnx_fail(focoos_instance: Focoos): - model_ref = "ref1" - focoos_instance.api_client.get = MagicMock(return_value=MagicMock(status_code=500)) - with tempfile.TemporaryDirectory() as model_dir_tmp: - focoos_instance.cache_dir = model_dir_tmp - with pytest.raises(ValueError): - focoos_instance._download_model(model_ref) - assert not (pathlib.Path(focoos_instance.cache_dir) / "model.onnx").exists() - - -def test_download_model_onnx_ok_but_get_external_fail(mocker: MockerFixture, focoos_instance: Focoos): - model_ref = "ref1" - # Mock successful API response for model metadata - focoos_instance.api_client.get = MagicMock( - return_value=MagicMock( - status_code=200, - json=lambda: { - "download_uri": "https://fake.com", - "model_metadata": MagicMock(), - }, - ), - ) - # Mock model metadata parsing - mock_model_metadata = mocker.patch("focoos.focoos.ModelMetadata.from_json", autospec=True) - mock_model_metadata.return_value = MagicMock(model_dump_json=lambda: "fake_model_dump") - - with tempfile.TemporaryDirectory() as model_dir_tmp: - focoos_instance.cache_dir = model_dir_tmp - # Mock failed download from Focoos Cloud - focoos_instance.api_client.download_file = MagicMock(side_effect=ValueError("Failed to download model")) - - # Should raise ValueError when download fails - with pytest.raises(ValueError, match="Failed to download model"): - focoos_instance._download_model(model_ref) - - # Verify no files were created - model_dir = pathlib.Path(model_dir_tmp) / model_ref - assert not (model_dir / "model.onnx").exists() - assert not (model_dir / "focoos_metadata.json").exists() - - -def test_download_model_onnx(mocker: MockerFixture, focoos_instance: Focoos): - with tempfile.TemporaryDirectory() as model_dir_tmp: - focoos_instance.cache_dir = model_dir_tmp - model_ref = "ref1" - expected_path = str(pathlib.Path(model_dir_tmp) / model_ref / "model.onnx") - focoos_instance.api_client.get = MagicMock( - return_value=MagicMock( - status_code=200, - json=lambda: { - "download_uri": "https://fake.com", - "model_metadata": MagicMock(), - }, - ), - ) - focoos_instance.api_client.external_get = MagicMock(return_value=MagicMock(status_code=200)) - focoos_instance.api_client.download_file = MagicMock(return_value=expected_path) - mock_model_metadata = mocker.patch("focoos.focoos.ModelMetadata.from_json", autospec=True) - mock_model_metadata.return_value = MagicMock(model_dump_json=lambda: "fake_model_dump") - focoos_instance.api_client.external_get = MagicMock( - return_value=MagicMock( - status_code=200, - headers={"content-length": 100}, - iter_content=lambda chunk_size: [ - b"chunk1", - b"chunk2", - b"chunk3", - b"chunk4", - ], - ) - ) - - model_path = focoos_instance._download_model(model_ref) - assert model_path is not None - assert model_path == expected_path diff --git a/tests/test_focoos_hub.py b/tests/test_focoos_hub.py new file mode 100644 index 00000000..6cd5d3ac --- /dev/null +++ b/tests/test_focoos_hub.py @@ -0,0 +1,335 @@ +import pathlib +import tempfile +from datetime import datetime +from typing import Generator +from unittest.mock import MagicMock, patch + +import pytest +from pytest_mock import MockerFixture + +from focoos import FocoosHUB +from focoos.config import FOCOOS_CONFIG +from focoos.hub.remote_model import RemoteModel +from focoos.infer.infer_model import InferModel +from focoos.ports import ArtifactName, ModelFamily, ModelInfo, ModelPreview, Task + + +@pytest.fixture +def focoos_instance() -> Generator[FocoosHUB, None, None]: + """Fixture to provide a Focoos instance with a mocked ApiClient.""" + with patch("focoos.hub.focoos_hub.ApiClient") as mock_api_client_class: + mock_api_client = MagicMock() + mock_api_client_class.return_value = mock_api_client + + # Mock the get_user_info response + mock_api_client.get.return_value.status_code = 200 + mock_api_client.get.return_value.json.return_value = { + "email": "test@example.com", + "created_at": "2024-01-01", + "updated_at": "2025-01-01", + "company": "test_company", + "api_key": {"key": "test_api_key"}, + "quotas": { + "total_inferences": 10, + "max_inferences": 1000, + "used_storage_gb": 10, + "max_storage_gb": 1000, + "active_training_jobs": ["job1"], + "max_active_training_jobs": 1, + "used_mlg4dnxlarge_training_jobs_hours": 10, + "max_mlg4dnxlarge_training_jobs_hours": 1000, + }, + } + + focoos_hub = FocoosHUB(api_key="test_api_key", host_url="http://mock-host-url.com") + # Replace the api_client with our mock after initialization + focoos_hub.api_client = mock_api_client + + yield focoos_hub + + +@pytest.fixture +def mock_shared_datasets(): + return [ + { + "ref": "79742c8814f94fcd", + "url": "s3://mock-s3-url", + "name": "Aeroscapes", + "layout": "supervisely", + "description": "Mock descr 1", + "task": "semseg", + }, + { + "ref": "cce71b2050be4e28", + "url": "s3://mock-s3-url", + "name": "Blister", + "layout": "roboflow_coco", + "description": "Mock descr 2", + "task": "instseg", + }, + ] + + +@pytest.fixture +def mock_list_models(): + return [ + { + "name": "model1", + "ref": "ref1", + "task": "detection", + "description": "model1 description", + "status": "TRAINING_COMPLETED", + "focoos_model": "focoos_rtdetr", + }, + { + "name": "model2", + "ref": "ref2", + "task": "detection", + "description": "model2 description", + "status": "TRAINING_RUNNING", + "focoos_model": "focoos_rtdetr", + }, + ] + + +@pytest.fixture +def mock_list_models_as_base_models(mock_list_models): + return [ModelPreview.from_json(r) for r in mock_list_models] + + +@pytest.fixture +def mock_remote_model(): + return MagicMock(spec=RemoteModel, model_ref="ref1") + + +@pytest.fixture +def mock_local_model(): + return MagicMock(spec=InferModel, name="model1", model_ref="ref1") + + +@pytest.fixture +def sample_model_info(): + """Fixture to provide a sample ModelInfo for testing.""" + return ModelInfo( + name="test-model", + model_family=ModelFamily.DETR, + classes=["class1", "class2"], + im_size=640, + task=Task.DETECTION, + config={}, + focoos_model="fai-detr-l-coco", + description="Test model description", + ) + + +def test_focoos_initialization_no_api_key(): + FOCOOS_CONFIG.focoos_api_key = "" + with pytest.raises(ValueError): + FocoosHUB(host_url="http://mock-host-url.com") + + +def test_focoos_initialization_fail_to_fetch_user_info(): + with patch("focoos.hub.focoos_hub.ApiClient") as mock_api_client_class: + mock_api_client = MagicMock() + mock_api_client_class.return_value = mock_api_client + mock_api_client.get.return_value.status_code = 500 + + with pytest.raises(ValueError): + FocoosHUB(api_key="test_api_key") + + +def test_focoos_initialization(focoos_instance: FocoosHUB): + assert focoos_instance.api_key == "test_api_key" + assert focoos_instance.user_info.email == "test@example.com" + assert focoos_instance.user_info.company == "test_company" + assert focoos_instance.user_info.created_at == datetime(2024, 1, 1) + assert focoos_instance.user_info.updated_at == datetime(2025, 1, 1) + assert focoos_instance.user_info.quotas.total_inferences == 10 + assert focoos_instance.user_info.quotas.max_inferences == 1000 + assert focoos_instance.user_info.quotas.used_storage_gb == 10 + assert focoos_instance.user_info.quotas.max_storage_gb == 1000 + assert focoos_instance.user_info.quotas.active_training_jobs == ["job1"] + assert focoos_instance.user_info.quotas.max_active_training_jobs == 1 + assert focoos_instance.user_info.quotas.used_mlg4dnxlarge_training_jobs_hours == 10 + assert focoos_instance.user_info.quotas.max_mlg4dnxlarge_training_jobs_hours == 1000 + + +def test_get_model_info(focoos_instance: FocoosHUB): + mock_response = { + "name": "test-model", + "ref": "model-ref", + "owner_ref": "pytest", + "focoos_model": "focoos_rtdetr", + "created_at": "2024-01-01", + "updated_at": "2024-01-01", + "description": "Test model description", + "task": "detection", + "status": "TRAINING_COMPLETED", + "is_managed": False, + } + focoos_instance.api_client.get = MagicMock(return_value=MagicMock(status_code=200, json=lambda: mock_response)) + model_info = focoos_instance.get_model_info("test-model") + assert model_info.name == "test-model" + assert model_info.ref == "model-ref" + assert model_info.description == "Test model description" + + +def test_get_model_info_fail(focoos_instance: FocoosHUB): + focoos_instance.api_client.get = MagicMock(return_value=MagicMock(status_code=500)) + + with pytest.raises(ValueError): + focoos_instance.get_model_info("test-model") + + +def test_list_models(focoos_instance: FocoosHUB, mock_list_models): + focoos_instance.api_client.get = MagicMock(return_value=MagicMock(status_code=200, json=lambda: mock_list_models)) + + models = focoos_instance.list_remote_models() + assert len(models) == 2 + assert models[0].name == "model1" + assert models[1].ref == "ref2" + + +def test_list_models_fail(focoos_instance: FocoosHUB): + focoos_instance.api_client.get = MagicMock(return_value=MagicMock(status_code=500)) + + with pytest.raises(ValueError): + focoos_instance.list_remote_models() + + +""" +unit tests get_model_by_name +""" + + +def test_get_remote_model(mocker: MockerFixture, focoos_instance: FocoosHUB, mock_remote_model): + mock_remote_model_class = mocker.patch("focoos.hub.focoos_hub.RemoteModel", autospec=True) + mock_remote_model_class.return_value = mock_remote_model + model_ref = "ref1" + model = focoos_instance.get_remote_model(model_ref) + assert model is not None + assert model.model_ref == model_ref + mock_remote_model_class.assert_called_once_with(model_ref, focoos_instance.api_client) + assert isinstance(model, RemoteModel) + + +def test_new_model_created( + mocker: MockerFixture, + focoos_instance: FocoosHUB, + mock_remote_model: RemoteModel, + sample_model_info: ModelInfo, +): + focoos_instance.api_client.post = MagicMock( + return_value=MagicMock( + status_code=201, + json=lambda: { + "ref": mock_remote_model.model_ref, + }, + ) + ) + mock_remote_model_class = mocker.patch("focoos.hub.focoos_hub.RemoteModel", autospec=True) + mock_remote_model_class.return_value = mock_remote_model + + model = focoos_instance.new_model(sample_model_info) + + assert model is not None + mock_remote_model_class.assert_called_once_with(mock_remote_model.model_ref, focoos_instance.api_client) + assert isinstance(model, RemoteModel) + + +def test_new_model_already_exists(mocker: MockerFixture, focoos_instance: FocoosHUB, sample_model_info: ModelInfo): + focoos_instance.api_client.post = MagicMock(return_value=MagicMock(status_code=409)) + + with pytest.raises(ValueError, match="Failed to create new model"): + focoos_instance.new_model(sample_model_info) + + +def test_new_model_fail(focoos_instance: FocoosHUB, sample_model_info: ModelInfo): + focoos_instance.api_client.post = MagicMock(return_value=MagicMock(status_code=500)) + + with pytest.raises(ValueError, match="Failed to create new model"): + focoos_instance.new_model(sample_model_info) + + +#! TODO: add test for download_model_pth_already_exists +# def test_download_model_pth_already_exists(focoos_instance: FocoosHUB): +# model_ref = "ref1" +# focoos_instance.api_client.get = MagicMock( +# return_value=MagicMock( +# status_code=200, +# json=lambda: { +# "download_uri": "https://fake.com/model_final.pth", +# }, +# ), +# ) +# with tempfile.TemporaryDirectory() as models_dir_tmp: +# # Patch MODELS_DIR in the focoos_hub module +# with patch("focoos.hub.focoos_hub.MODELS_DIR", models_dir_tmp): +# model_dir_tmp = pathlib.Path(models_dir_tmp) / model_ref +# model_dir_tmp.mkdir(parents=True, exist_ok=True) +# # Create the file with the correct name that the method looks for +# model_pth_path = model_dir_tmp / ArtifactName.WEIGHTS +# model_pth_path.touch() + +# # Since the file exists, no API calls should be made +# # The method should return early without calling the API +# model_path = focoos_instance.download_model_pth(model_ref) +# assert model_path is not None +# assert model_path == str(model_dir_tmp / ArtifactName.WEIGHTS) + + +def test_download_model_pth_fail(focoos_instance: FocoosHUB): + model_ref = "ref1" + focoos_instance.api_client.get = MagicMock(return_value=MagicMock(status_code=500)) + + with pytest.raises(ValueError, match="Failed to retrieve download url for model"): + focoos_instance.download_model_pth(model_ref) + + +def test_download_model_pth_ok_but_get_external_fail(mocker: MockerFixture, focoos_instance: FocoosHUB): + model_ref = "ref1" + # Mock successful API response for model download URI + focoos_instance.api_client.get = MagicMock( + return_value=MagicMock( + status_code=200, + json=lambda: { + "download_uri": "https://fake.com/model.pth", + }, + ), + ) + + with tempfile.TemporaryDirectory() as models_dir_tmp: + # Patch MODELS_DIR in the focoos_hub module + with patch("focoos.hub.focoos_hub.MODELS_DIR", models_dir_tmp): + # Mock failed download from Focoos Cloud + focoos_instance.api_client.download_ext_file = MagicMock(side_effect=ValueError("Failed to download model")) + + # Should raise ValueError when download fails + with pytest.raises(ValueError, match="Failed to download model"): + focoos_instance.download_model_pth(model_ref) + + # Verify no files were created + model_dir = pathlib.Path(models_dir_tmp) / model_ref + assert not (model_dir / ArtifactName.WEIGHTS).exists() + + +def test_download_model_pth_success(mocker: MockerFixture, focoos_instance: FocoosHUB): + with tempfile.TemporaryDirectory() as models_dir_tmp: + # Patch MODELS_DIR in the focoos_hub module + with patch("focoos.hub.focoos_hub.MODELS_DIR", models_dir_tmp): + model_ref = "ref1" + expected_path = str(pathlib.Path(models_dir_tmp) / model_ref / ArtifactName.WEIGHTS) + + focoos_instance.api_client.get = MagicMock( + return_value=MagicMock( + status_code=200, + json=lambda: { + "download_uri": "https://fake.com/model.pth", + }, + ), + ) + focoos_instance.api_client.download_ext_file = MagicMock(return_value=expected_path) + + model_path = focoos_instance.download_model_pth(model_ref) + assert model_path is not None + assert model_path == expected_path diff --git a/tests/test_infer_model.py b/tests/test_infer_model.py new file mode 100644 index 00000000..97d78698 --- /dev/null +++ b/tests/test_infer_model.py @@ -0,0 +1,261 @@ +import json +from dataclasses import asdict +from unittest.mock import MagicMock + +import numpy as np +import pytest +from pytest_mock import MockerFixture + +from focoos.infer.infer_model import InferModel +from focoos.infer.runtimes.onnx import ONNXRuntime +from focoos.infer.runtimes.torchscript import TorchscriptRuntime +from focoos.ports import ( + FocoosDet, + FocoosDetections, + LatencyMetrics, + ModelFamily, + ModelInfo, + RuntimeType, + Task, +) + + +@pytest.fixture +def mock_model_info(): + """Fixture to provide a mock ModelInfo for testing.""" + return ModelInfo( + name="test-model", + model_family=ModelFamily.DETR, + classes=["class_0", "class_1"], + im_size=640, + task=Task.DETECTION, + config={}, + ref="test_model_ref", + focoos_model="test_focoos_model", + description="A test model for unit tests", + ) + + +@pytest.fixture +def mock_model_dir(tmp_path, mock_model_info: ModelInfo): + model_dir = tmp_path / "model" + model_dir.mkdir() + model_info_path = model_dir / "model_info.json" + model_info_path.write_text(json.dumps(asdict(mock_model_info))) + (model_dir / "model.onnx").touch() + (model_dir / "model.pt").touch() + return model_dir + + +@pytest.fixture +def mock_local_model_onnx(mocker: MockerFixture, mock_model_dir): + # Mock get_runtime + mock_runtime = MagicMock(spec=ONNXRuntime) + mock_get_runtime = mocker.patch("focoos.infer.infer_model.load_runtime") + mock_get_runtime.return_value = mock_runtime + + # Mock processor and config manager + mocker.patch("focoos.infer.infer_model.ProcessorManager.get_processor") + mocker.patch("focoos.model_manager.ConfigManager.from_dict") + + model = InferModel(model_dir=mock_model_dir, runtime_type=RuntimeType.ONNX_CPU) + return model + + +@pytest.fixture +def mock_local_model_torch(mocker: MockerFixture, mock_model_dir): + # Mock get_runtime + mock_runtime = MagicMock(spec=TorchscriptRuntime) + mock_get_runtime = mocker.patch("focoos.infer.infer_model.load_runtime") + mock_get_runtime.return_value = mock_runtime + + # Mock processor and config manager + mocker.patch("focoos.infer.infer_model.ProcessorManager.get_processor") + mocker.patch("focoos.model_manager.ConfigManager.from_dict") + + model = InferModel(model_dir=mock_model_dir, runtime_type=RuntimeType.TORCHSCRIPT_32) + return model + + +def test_initialization_fail_no_model_dir(): + with pytest.raises(FileNotFoundError): + InferModel(model_dir="fakedir", runtime_type=RuntimeType.ONNX_CPU) + + +def test_init_file_not_found(mocker: MockerFixture): + mocker.patch("focoos.infer.infer_model.os.path.exists", return_value=False) + with pytest.raises(FileNotFoundError): + InferModel(model_dir="fakedir", runtime_type=RuntimeType.ONNX_CPU) + + +def test_initialization_onnx(mock_local_model_onnx: InferModel, mock_model_dir, mock_model_info): + assert mock_local_model_onnx.model_dir == mock_model_dir + assert mock_local_model_onnx.model_info.name == mock_model_info.name + assert isinstance(mock_local_model_onnx.runtime, MagicMock) + + +def test_initialization_torch(mock_local_model_torch: InferModel, mock_model_dir, mock_model_info): + assert mock_local_model_torch.model_dir == mock_model_dir + assert mock_local_model_torch.model_info.name == mock_model_info.name + assert isinstance(mock_local_model_torch.runtime, MagicMock) + + +def test_benchmark(mock_local_model_onnx: InferModel): + mock_local_model_onnx.runtime.benchmark.return_value = MagicMock(spec=LatencyMetrics) + iterations, size = 10, 1000 + + result = mock_local_model_onnx.benchmark(iterations, size) + + assert result is not None + assert isinstance(result, MagicMock) + mock_local_model_onnx.runtime.benchmark.assert_called_once_with(iterations, (size, size)) + + +@pytest.fixture +def mock_focoos_detections(): + mock_detection = FocoosDet( + cls_id=0, + conf=0.95, + bbox=[10, 10, 50, 50], + ) + return FocoosDetections( + detections=[mock_detection], + latency={"inference": 0.1, "preprocess": 0.05, "postprocess": 0.02}, + ) + + +def test_infer_onnx( + mocker: MockerFixture, + mock_local_model_onnx: InferModel, + image_ndarray: np.ndarray, + mock_focoos_detections: FocoosDetections, +): + # Mock image preprocessing + mock_image_preprocess = mocker.patch("focoos.infer.infer_model.image_preprocess") + mock_image_preprocess.return_value = (image_ndarray, image_ndarray) + + # Mock processor methods + mock_local_model_onnx.processor.preprocess = MagicMock(return_value=(MagicMock(), None)) + mock_local_model_onnx.processor.export_postprocess = MagicMock(return_value=[mock_focoos_detections]) + + # Mock runtime call + mock_local_model_onnx.runtime = MagicMock() + mock_local_model_onnx.runtime.return_value = MagicMock() + + # Act + result = mock_local_model_onnx.infer(image=image_ndarray, threshold=0.5) + + # Assertions + assert result is not None + assert isinstance(result, FocoosDetections) + mock_image_preprocess.assert_called_once() + mock_local_model_onnx.processor.preprocess.assert_called_once() + mock_local_model_onnx.processor.export_postprocess.assert_called_once() + + +def test_infer_torch( + mocker: MockerFixture, + mock_local_model_torch: InferModel, + image_ndarray: np.ndarray, + mock_focoos_detections: FocoosDetections, +): + # Mock image preprocessing + mock_image_preprocess = mocker.patch("focoos.infer.infer_model.image_preprocess") + mock_image_preprocess.return_value = (image_ndarray, image_ndarray) + + # Mock processor methods + mock_local_model_torch.processor.preprocess = MagicMock(return_value=(MagicMock(), None)) + mock_local_model_torch.processor.export_postprocess = MagicMock(return_value=[mock_focoos_detections]) + + # Mock runtime call + mock_local_model_torch.runtime = MagicMock() + mock_local_model_torch.runtime.return_value = MagicMock() + + # Act + result = mock_local_model_torch.infer(image=image_ndarray, threshold=0.5) + + # Assertions + assert result is not None + assert isinstance(result, FocoosDetections) + mock_image_preprocess.assert_called_once() + mock_local_model_torch.processor.preprocess.assert_called_once() + mock_local_model_torch.processor.export_postprocess.assert_called_once() + + +def test_call_method( + mocker: MockerFixture, + mock_local_model_onnx: InferModel, + image_ndarray: np.ndarray, + mock_focoos_detections: FocoosDetections, +): + # Mock the infer method + mock_infer = mocker.patch.object(mock_local_model_onnx, "infer", return_value=mock_focoos_detections) + + # Act + result = mock_local_model_onnx(image=image_ndarray, threshold=0.5) + + # Assertions + assert result is not None + assert isinstance(result, FocoosDetections) + mock_infer.assert_called_once_with(image_ndarray, 0.5) + + +def test_end2end_benchmark(mocker: MockerFixture, mock_local_model_onnx: InferModel): + # Mock runtime.get_info + mock_local_model_onnx.runtime.get_info = MagicMock(return_value=("ONNX", "CPU")) + + # Mock the infer method instead of __call__ to avoid the actual inference logic + mock_infer = mocker.patch.object(mock_local_model_onnx, "infer", return_value=MagicMock()) + + # Act + result = mock_local_model_onnx.end2end_benchmark(iterations=5, size=640) + + # Assertions + assert result is not None + assert isinstance(result, LatencyMetrics) + assert result.engine == "ONNX" + assert result.device == "CPU" + assert result.im_size == 640 + # The method adds 5 warmup iterations, so total calls = iterations + 5 + assert mock_infer.call_count == 10 # 5 iterations + 5 warmup iterations + + +def test_read_model_info_file_not_found(mocker: MockerFixture, tmp_path): + # Create model directory without model_info.json + model_dir = tmp_path / "model" + model_dir.mkdir() + + # Mock os.path.exists to return False for model.onnx check but True for directory + def mock_exists(path): + if "model.onnx" in str(path): + return True + if "model_info.json" in str(path): + return False + return True + + mocker.patch("focoos.infer.infer_model.os.path.exists", side_effect=mock_exists) + + with pytest.raises(FileNotFoundError, match="Model info file not found"): + InferModel(model_dir=model_dir, runtime_type=RuntimeType.ONNX_CPU) + + +def test_benchmark_with_default_size(mock_local_model_onnx: InferModel): + mock_local_model_onnx.runtime.benchmark.return_value = MagicMock(spec=LatencyMetrics) + iterations = 10 + + result = mock_local_model_onnx.benchmark(iterations) + + assert result is not None + mock_local_model_onnx.runtime.benchmark.assert_called_once_with( + iterations, (mock_local_model_onnx.model_info.im_size, mock_local_model_onnx.model_info.im_size) + ) + + +def test_benchmark_with_tuple_size(mock_local_model_onnx: InferModel): + mock_local_model_onnx.runtime.benchmark.return_value = MagicMock(spec=LatencyMetrics) + iterations, size = 10, (800, 600) + + result = mock_local_model_onnx.benchmark(iterations, size) + + assert result is not None + mock_local_model_onnx.runtime.benchmark.assert_called_once_with(iterations, size) diff --git a/tests/test_local_model.py b/tests/test_local_model.py deleted file mode 100644 index e8b39fd9..00000000 --- a/tests/test_local_model.py +++ /dev/null @@ -1,259 +0,0 @@ -from unittest.mock import MagicMock - -import numpy as np -import pytest -import supervision as sv -from pytest_mock import MockerFixture - -from focoos.local_model import LocalModel -from focoos.ports import ( - FocoosDet, - FocoosDetections, - FocoosTask, - LatencyMetrics, - ModelMetadata, - RuntimeTypes, -) -from focoos.runtime import ONNXRuntime, TorchscriptRuntime - - -@pytest.fixture -def mock_model_dir(tmp_path, mock_metadata: ModelMetadata): - model_dir = tmp_path / "model" - model_dir.mkdir() - metadata_path = model_dir / "focoos_metadata.json" - metadata_path.write_text(mock_metadata.model_dump_json()) - (model_dir / "model.onnx").touch() - return model_dir - - -@pytest.fixture -def mock_local_model_onnx(mocker: MockerFixture, mock_model_dir, image_ndarray): - # Mock get_runtime - mock_runtime = MagicMock(spec=ONNXRuntime) - mock_get_runtime = mocker.patch("focoos.local_model.load_runtime", mock_runtime) - mock_get_runtime.return_value = mock_runtime - mocker.patch("focoos.local_model.os.path.exists", return_value=True) - model = LocalModel(model_dir=mock_model_dir, runtime_type=RuntimeTypes.ONNX_CPU) - - # Mock BoxAnnotator - mock_box_annotator = mocker.patch("focoos.local_model.sv.BoxAnnotator", autospec=True) - mock_box_annotator.annotate = MagicMock(return_value=np.zeros_like(image_ndarray)) - - # Mock LabelAnnotator - mock_label_annotator = mocker.patch("focoos.local_model.sv.LabelAnnotator", autospec=True) - mock_label_annotator.annotate = MagicMock(return_value=np.zeros_like(image_ndarray)) - - # Mock MaskAnnotator - mock_mask_annotator = mocker.patch("focoos.local_model.sv.MaskAnnotator", autospec=True) - mock_mask_annotator.annotate = MagicMock(return_value=np.zeros_like(image_ndarray)) - - # Inject mock annotators into the local model - model.box_annotator = mock_box_annotator - model.label_annotator = mock_label_annotator - model.mask_annotator = mock_mask_annotator - return model - - -@pytest.fixture -def mock_local_model_torch(mocker: MockerFixture, mock_model_dir, image_ndarray): - # Mock get_runtime - mock_runtime = MagicMock(spec=TorchscriptRuntime) - mock_get_runtime = mocker.patch("focoos.local_model.load_runtime", mock_runtime) - mock_get_runtime.return_value = mock_runtime - mocker.patch("focoos.local_model.os.path.exists", return_value=True) - model = LocalModel(model_dir=mock_model_dir, runtime_type=RuntimeTypes.TORCHSCRIPT_32) - - # Mock BoxAnnotator - mock_box_annotator = mocker.patch("focoos.local_model.sv.BoxAnnotator", autospec=True) - mock_box_annotator.annotate = MagicMock(return_value=np.zeros_like(image_ndarray)) - - # Mock LabelAnnotator - mock_label_annotator = mocker.patch("focoos.local_model.sv.LabelAnnotator", autospec=True) - mock_label_annotator.annotate = MagicMock(return_value=np.zeros_like(image_ndarray)) - - # Mock MaskAnnotator - mock_mask_annotator = mocker.patch("focoos.local_model.sv.MaskAnnotator", autospec=True) - mock_mask_annotator.annotate = MagicMock(return_value=np.zeros_like(image_ndarray)) - - # Inject mock annotators into the local model - model.box_annotator = mock_box_annotator - model.label_annotator = mock_label_annotator - model.mask_annotator = mock_mask_annotator - return model - - -def test_initialization_fail_no_model_dir(): - with pytest.raises(FileNotFoundError): - LocalModel(model_dir="fakedir", runtime_type=RuntimeTypes.ONNX_CPU) - - -def test_init_file_not_found(mocker: MockerFixture): - mocker.patch("focoos.local_model.os.path.exists", return_value=False) - with pytest.raises(FileNotFoundError): - LocalModel(model_dir="fakedir", runtime_type=RuntimeTypes.ONNX_CPU) - - -def test_initialization_onnx(mock_local_model_onnx: LocalModel, mock_model_dir, mock_metadata): - assert mock_local_model_onnx.model_dir == mock_model_dir - assert mock_local_model_onnx.metadata == mock_metadata - assert isinstance(mock_local_model_onnx.runtime, ONNXRuntime) - - -def test_initialization_torch(mock_local_model_torch: LocalModel, mock_model_dir, mock_metadata): - assert mock_local_model_torch.model_dir == mock_model_dir - assert mock_local_model_torch.metadata == mock_metadata - assert isinstance(mock_local_model_torch.runtime, TorchscriptRuntime) - - -def test_benchmark(mock_local_model_onnx: LocalModel): - mock_local_model_onnx.runtime.benchmark.return_value = MagicMock(spec=LatencyMetrics) - iterations, size = 10, 1000 - - result = mock_local_model_onnx.benchmark(iterations, size) - - assert result is not None - assert isinstance(result, LatencyMetrics) - mock_local_model_onnx.runtime.benchmark.assert_called_once_with(iterations, size) - - -@pytest.fixture -def mock_focoos_detections(): - mock_detection = FocoosDet( - cls_id=0, - conf=0.95, - bbox=[10, 10, 50, 50], - ) - return FocoosDetections( - detections=[mock_detection], - latency={"inference": 0.1, "preprocess": 0.05, "postprocess": 0.02}, - ) - - -@pytest.fixture -def mock_sv_detections() -> sv.Detections: - return sv.Detections( - xyxy=np.array([[2, 8, 16, 32], [4, 10, 18, 34]]), - class_id=np.array([0, 1]), - confidence=np.array([0.8, 0.9]), - ) - - -@pytest.fixture -def mock_runtime_detections() -> list[np.ndarray]: - return [np.array([[2, 8, 16, 32], [4, 10, 18, 34]]), np.array([0, 1]), np.array([0.8, 0.9])] - - -def test_annotate_detection_metadata_classes_none( - image_ndarray: np.ndarray, mock_local_model_onnx: LocalModel, mock_sv_detections -): - mock_local_model_onnx.metadata.classes = None - annotated_im = mock_local_model_onnx._annotate(image_ndarray, mock_sv_detections) - assert annotated_im is not None - assert isinstance(annotated_im, np.ndarray) - mock_local_model_onnx.box_annotator.annotate.assert_called_once() - mock_local_model_onnx.label_annotator.annotate.assert_called_once() - mock_local_model_onnx.mask_annotator.annotate.assert_not_called() - - -def test_annotate_detection(image_ndarray: np.ndarray, mock_local_model_onnx: LocalModel, mock_sv_detections): - annotated_im = mock_local_model_onnx._annotate(image_ndarray, mock_sv_detections) - assert annotated_im is not None - assert isinstance(annotated_im, np.ndarray) - mock_local_model_onnx.box_annotator.annotate.assert_called_once() - mock_local_model_onnx.label_annotator.annotate.assert_called_once() - mock_local_model_onnx.mask_annotator.annotate.assert_not_called() - - -def test_annotate_semseg(image_ndarray: np.ndarray, mock_local_model_onnx: LocalModel, mock_sv_detections): - mock_local_model_onnx.metadata.task = FocoosTask.SEMSEG - annotated_im = mock_local_model_onnx._annotate(image_ndarray, mock_sv_detections) - assert annotated_im is not None - assert isinstance(annotated_im, np.ndarray) - mock_local_model_onnx.box_annotator.annotate.asser_not_called() - mock_local_model_onnx.label_annotator.annotate.asser_not_called() - mock_local_model_onnx.mask_annotator.annotate.assert_called_once() - - -def mock_infer_setup( - mocker: MockerFixture, - mock_local_model: LocalModel, - image_ndarray: np.ndarray, - mock_sv_detections: sv.Detections, - mock_runtime_detections: list[np.ndarray], - mock_focoos_detections: FocoosDetections, - annotate: bool, -): - """Setup for mocking the infer method.""" - # Mock image_preprocess - mock_image_preprocess = mocker.patch("focoos.local_model.image_preprocess") - mock_image_preprocess.return_value = (image_ndarray, image_ndarray) - - # Mock sv_to_focoos_detections - mock_sv_to_focoos_detections = mocker.patch("focoos.local_model.sv_to_fai_detections") - mock_sv_to_focoos_detections.return_value = mock_focoos_detections.detections - - # mock postprocess - mock_postprocess = mocker.patch.object(mock_local_model, "postprocess_fn") - mock_postprocess.return_value = mock_sv_detections - - # Mock _annotate - mock_annotate = mocker.patch.object(mock_local_model, "_annotate", autospec=True) - if annotate: - mock_annotate.return_value = image_ndarray - else: - mock_annotate.return_value = None # No annotation if False - - # Mock runtime - class MockRuntime(MagicMock): - def __call__(self, *args, **kwargs): - return mock_runtime_detections - - mock_runtime_call = mocker.patch.object(MockRuntime, "__call__", return_value=mock_runtime_detections) - mock_local_model.runtime = MockRuntime(spec=ONNXRuntime) - - return ( - mock_image_preprocess, - mock_runtime_call, - mock_sv_to_focoos_detections, - mock_annotate, - ) - - -@pytest.mark.parametrize("annotate", [(False, None)]) -def test_infer_onnx( - mocker, - mock_local_model_onnx, - image_ndarray, - mock_sv_detections, - mock_focoos_detections, - mock_runtime_detections, - annotate, -): - # Arrange - *mock_to_call_once, mock_annotate = mock_infer_setup( - mocker, - mock_local_model_onnx, - image_ndarray, - mock_sv_detections, - mock_runtime_detections, - mock_focoos_detections, - annotate, - ) - - # Act - out, im = mock_local_model_onnx.infer(image=image_ndarray, annotate=annotate) - - # Assertions - assert out is not None - assert isinstance(out, FocoosDetections) - - for mock_obj in mock_to_call_once: - mock_obj.assert_called_once() - if annotate: - mock_annotate.assert_called_once() - assert im is not None - assert isinstance(im, np.ndarray) - else: - mock_annotate.assert_not_called() - assert im is None diff --git a/tests/test_model_manager.py b/tests/test_model_manager.py new file mode 100644 index 00000000..7f2e1703 --- /dev/null +++ b/tests/test_model_manager.py @@ -0,0 +1,776 @@ +import importlib +import os +from dataclasses import dataclass +from typing import Any, Type +from unittest.mock import MagicMock + +import pytest +from pytest_mock import MockerFixture + +from focoos.model_manager import ConfigBackboneManager, ModelManager +from focoos.models.focoos_model import BaseModelNN, FocoosModel +from focoos.ports import ModelConfig, ModelFamily, ModelInfo, Task + + +@pytest.fixture +def mock_model_config(): + """Fixture to provide a mock ModelConfig.""" + return ModelConfig( + num_classes=10, + ) + + +@pytest.fixture +def mock_model_info(): + """Fixture to provide a mock ModelInfo.""" + model_info = ModelInfo( + name="test-model", + model_family=ModelFamily.DETR, + classes=["class1", "class2"], + im_size=640, + task=Task.DETECTION, + config={}, + ) + return model_info + + +@pytest.fixture +def mock_focoos_model(): + """Fixture to provide a mock get_infer_model method.""" + return MagicMock(spec=FocoosModel) + + +@pytest.fixture +def functional_model_manager(): + """Fixture to provide a functional ModelManager for testing.""" + original_models_map = ModelManager._models_family_map.copy() + + # Clear mappings for test + ModelManager._models_family_map = {} + + yield ModelManager # Provide clean ModelManager to test + + # Restore original mappings after test + ModelManager._models_family_map = original_models_map + + +@pytest.fixture +def clean_model_manager(mock_focoos_model: FocoosModel): + """Fixture to provide a clean ModelManager for testing. + + This fixture saves the original model mappings, clears them for the test, + and restores them afterward regardless of test outcome. + """ + # Save original mappings + original_models_map = ModelManager._models_family_map.copy() + original_from_model_info = ModelManager._from_model_info + original_from_local_dir = ModelManager._from_local_dir + original_from_hub = ModelManager._from_hub + + # Clear mappings for test + ModelManager._models_family_map = {} + ModelManager._from_model_info = MagicMock(return_value=mock_focoos_model) + ModelManager._from_local_dir = MagicMock(return_value=mock_focoos_model) + ModelManager._from_hub = MagicMock(return_value=(mock_focoos_model, MagicMock())) + + yield ModelManager # Provide clean ModelManager to test + + # Restore original mappings after test + ModelManager._models_family_map = original_models_map + ModelManager._from_model_info = original_from_model_info + ModelManager._from_local_dir = original_from_local_dir + ModelManager._from_hub = original_from_hub + + +@pytest.fixture +def dirty_model_manager(mock_focoos_model: FocoosModel): + """Fixture to provide a clean ModelManager for testing. + + This fixture saves the original model mappings, clears them for the test, + and restores them afterward regardless of test outcome. + """ + # Save original mappings + original_models_map = ModelManager._models_family_map.copy() + original_from_model_info = ModelManager._from_model_info + original_from_local_dir = ModelManager._from_local_dir + original_from_hub = ModelManager._from_hub + original_register_model = ModelManager.register_model + + # Clear mappings for test + # Create a mock model loader function + def mock_model_loader() -> Type[BaseModelNN]: + # This would return a model class in real code + return BaseModelNN + + ModelManager._models_family_map = {ModelFamily.DETR.value: mock_model_loader} + ModelManager._from_model_info = MagicMock(return_value=mock_focoos_model) + ModelManager._from_local_dir = MagicMock(return_value=mock_focoos_model) + ModelManager._from_hub = MagicMock(return_value=(mock_focoos_model, MagicMock())) + ModelManager.register_model = MagicMock() + + yield ModelManager # Provide clean ModelManager to test + + # Restore original mappings after test + ModelManager._models_family_map = original_models_map + ModelManager._from_model_info = original_from_model_info + ModelManager._from_local_dir = original_from_local_dir + ModelManager._from_hub = original_from_hub + ModelManager.register_model = original_register_model + + +def test_register_model(clean_model_manager): + """Test that ModelManager.register_model correctly registers a model loader.""" + + # Create a mock model loader function + def mock_model_loader() -> Type[BaseModelNN]: + # This would return a model class in real code + return BaseModelNN + + # Test model family + test_family = ModelFamily.DETR + + # Register the mock model loader + clean_model_manager.register_model(test_family, mock_model_loader) + + # Check that the model loader was registered + assert test_family.value in clean_model_manager._models_family_map + assert clean_model_manager._models_family_map[test_family.value] == mock_model_loader + + # Test retrieving the model class + model_class = clean_model_manager._models_family_map[test_family.value]() + assert model_class == BaseModelNN + + +def test_get_with_model_info_without_kwargs(clean_model_manager, mock_model_info): + """Test that ModelManager.get_infer_model correctly retrieves a model.""" + model = clean_model_manager.get(name="test-model", model_info=mock_model_info) + clean_model_manager._from_model_info.assert_called_once_with(model_info=mock_model_info, config=None) + assert isinstance(model, FocoosModel) + + +def test_get_with_model_info_with_kwargs(clean_model_manager, mock_model_info): + """Test that ModelManager.get_infer_model correctly retrieves a model.""" + model = clean_model_manager.get(name="test-model", model_info=mock_model_info, pluto="test-pluto") + clean_model_manager._from_model_info.assert_called_once_with( + model_info=mock_model_info, config=None, pluto="test-pluto" + ) + assert isinstance(model, FocoosModel) + + +def test_get_with_model_info_with_config(clean_model_manager, mock_model_config, mock_model_info): + """Test that ModelManager.get_infer_model correctly retrieves a model.""" + model = clean_model_manager.get(name="test-model", model_info=mock_model_info, config=mock_model_config) + clean_model_manager._from_model_info.assert_called_once_with(model_info=mock_model_info, config=mock_model_config) + assert isinstance(model, FocoosModel) + + +def test_get_with_get_model_hub(clean_model_manager): + """Test that ModelManager.get_infer_model correctly retrieves a model.""" + mock_hub = MagicMock() + model = clean_model_manager.get(name="hub://test-model", hub=mock_hub) + clean_model_manager._from_hub.assert_called_once_with(hub_uri="hub://test-model", hub=mock_hub, cache=True) + assert isinstance(model, FocoosModel) + + +def test_get_with_get_model_local_dir(clean_model_manager, mock_model_config): + """Test that ModelManager.get_infer_model correctly retrieves a model.""" + # Setup mocks so that ModelRegistry.exists returns False to trigger local dir path + with MagicMock() as mock_registry: + mock_registry.exists.return_value = False + mock_model_info = MagicMock(spec=ModelInfo) + clean_model_manager._from_local_dir.return_value = mock_model_info + + model = clean_model_manager.get(name="test-model") + clean_model_manager._from_local_dir.assert_called_once_with(name="test-model", models_dir=None) + clean_model_manager._from_model_info.assert_called_once_with(model_info=mock_model_info, config=None) + assert isinstance(model, FocoosModel) + + +def test_get_with_get_model_local_dir_with_config(clean_model_manager, mock_model_config): + """Test that ModelManager.get_infer_model correctly retrieves a model.""" + # Setup mocks so that ModelRegistry.exists returns False to trigger local dir path + with MagicMock() as mock_registry: + mock_registry.exists.return_value = False + mock_model_info = MagicMock(spec=ModelInfo) + clean_model_manager._from_local_dir.return_value = mock_model_info + + model = clean_model_manager.get(name="test-model", config=mock_model_config) + clean_model_manager._from_local_dir.assert_called_once_with( + name="test-model", + models_dir=None, + ) + clean_model_manager._from_model_info.assert_called_once_with( + model_info=mock_model_info, config=mock_model_config + ) + assert isinstance(model, FocoosModel) + + +def test_get_with_get_model_local_dir_with_model_dir(clean_model_manager, mock_model_config): + """Test that ModelManager.get_infer_model correctly retrieves a model.""" + # Setup mocks so that ModelRegistry.exists returns False to trigger local dir path + with MagicMock() as mock_registry: + mock_registry.exists.return_value = False + mock_model_info = MagicMock(spec=ModelInfo) + clean_model_manager._from_local_dir.return_value = mock_model_info + + model = clean_model_manager.get(name="test-model", models_dir="test-models-dir") + clean_model_manager._from_local_dir.assert_called_once_with( + name="test-model", + models_dir="test-models-dir", + ) + clean_model_manager._from_model_info.assert_called_once_with(model_info=mock_model_info, config=None) + assert isinstance(model, FocoosModel) + + +def test_get_with_get_model_registry(mocker: MockerFixture, clean_model_manager, mock_model_info): + """Test that ModelManager.get_infer_model correctly retrieves a model.""" + + mocker.patch("focoos.model_manager.ModelRegistry.exists", return_value=True) + mocker.patch("focoos.model_manager.ModelRegistry.get_model_info", return_value=mock_model_info) + model = clean_model_manager.get(name="test-model") + clean_model_manager._from_model_info.assert_called_once_with(model_info=mock_model_info, config=None) + assert isinstance(model, FocoosModel) + + +def test_ensure_family_registered_already_registered(mocker: MockerFixture, dirty_model_manager): + patched_function = mocker.patch("focoos.model_manager.importlib.import_module", return_value=MagicMock()) + dirty_model_manager._ensure_family_registered(ModelFamily.DETR) + dirty_model_manager.register_model.assert_not_called() + patched_function.assert_not_called() + + +def test_ensure_family_registered_not_registered(mocker: MockerFixture, clean_model_manager): + fake_family_module = MagicMock() + fake_family_module._register = MagicMock() + mocker.patch("focoos.model_manager.importlib.import_module", return_value=fake_family_module) + clean_model_manager._ensure_family_registered(ModelFamily.DETR) + fake_family_module._register.assert_called_once() + + +def test_from_model_info_with_model_registry(mocker: MockerFixture, functional_model_manager, mock_model_info): + # Mock the necessary components + mock_model_class = MagicMock() + mock_nn_model = MagicMock() + mock_model_class.return_value = mock_nn_model + + # Mock the _ensure_family_registered method + functional_model_manager._ensure_family_registered = MagicMock() + + # Mock the _models_family_map to return our mock_model_class + functional_model_manager._models_family_map = {mock_model_info.model_family.value: lambda: mock_model_class} + + # Mock ConfigManager + mock_config = MagicMock() + mocker.patch("focoos.model_manager.ConfigManager.from_dict", return_value=mock_config) + + # Call the method + result = functional_model_manager._from_model_info(model_info=mock_model_info) + + # Assertions + functional_model_manager._ensure_family_registered.assert_called_once_with(mock_model_info.model_family) + mock_model_class.assert_called_once_with(mock_config) + assert isinstance(result, FocoosModel) + assert result.model == mock_nn_model + assert result.model_info == mock_model_info + + +def test_from_model_info_with_custom_config( + mocker: MockerFixture, functional_model_manager, mock_focoos_model, mock_model_info, mock_model_config +): + # Mock the necessary components + mocker.patch("focoos.model_manager.FocoosModel", return_value=mock_focoos_model) + mock_model_class = MagicMock() + mock_nn_model = MagicMock() + mock_model_class.return_value = mock_nn_model + + # Mock the _ensure_family_registered method + mocker.patch.object(functional_model_manager, "_ensure_family_registered") + + # Mock the _models_family_map to return our mock_model_class + functional_model_manager._models_family_map = {mock_model_info.model_family.value: lambda: mock_model_class} + + # Call the method with custom config + result = functional_model_manager._from_model_info(model_info=mock_model_info, config=mock_model_config) + + # Assertions + functional_model_manager._ensure_family_registered.assert_called_once_with(mock_model_info.model_family) + mock_model_class.assert_called_once_with(mock_model_config) + assert isinstance(result, FocoosModel) + + +def test_from_model_info_with_weights(mocker: MockerFixture, functional_model_manager, mock_model_info): + # Mock the necessary components + mock_model_class = MagicMock() + mock_nn_model = MagicMock() + mock_model_class.return_value = mock_nn_model + mock_focoos_model = MagicMock(spec=FocoosModel) + mock_focoos_model.load_weights = MagicMock() + + # Set weights_uri + mock_model_info.weights_uri = "model_weights.pth" + + # Mock the _ensure_family_registered method + mocker.patch.object(functional_model_manager, "_ensure_family_registered") + + # Mock the FocoosModel creation + mocker.patch("focoos.model_manager.FocoosModel", return_value=mock_focoos_model) + + # Mock the _models_family_map to return our mock_model_class + mock_model_class_function = MagicMock() + mock_model_class_function.return_value = mock_model_class + functional_model_manager._models_family_map = {mock_model_info.model_family.value: mock_model_class_function} + + # Mock ConfigManager - removed ArtifactsManager as it doesn't exist + mock_config = MagicMock() + mocker.patch("focoos.model_manager.ConfigManager.from_dict", return_value=mock_config) + + # Call the method + result = functional_model_manager._from_model_info(model_info=mock_model_info) + + # Assertions + mock_model_class_function.assert_called_once() + mock_model_class.assert_called_once() + # Note: load_weights is not called in the current implementation + # The weights loading is handled differently now + assert result == mock_focoos_model + + +def test_from_model_info_unsupported_family(functional_model_manager, mock_model_info): + # Create a mock model family that's not in the _models_family_map + # Using a different model family than what's in the maps + unsupported_family = list(ModelFamily)[0] # Get first enum value + mock_model_info.model_family = unsupported_family + + # Mock the _ensure_family_registered method to do nothing + functional_model_manager._ensure_family_registered = MagicMock() + + # Clear the _models_family_map + functional_model_manager._models_family_map = {} + + # Call the method and expect a ValueError + with pytest.raises(ValueError, match=f"Model {unsupported_family} not supported"): + functional_model_manager._from_model_info(model_info=mock_model_info) + + +def test_from_local_dir_success(mocker: MockerFixture, functional_model_manager, mock_model_info): + # Mock os.path functions + mocker.patch("os.path.exists", return_value=True) + + # Mock ModelInfo.from_json + mocker.patch("focoos.ports.ModelInfo.from_json", return_value=mock_model_info) + + # Call the method + result = functional_model_manager._from_local_dir(name="test-model", models_dir="/path/to/models") + + # Assertions - _from_local_dir returns ModelInfo, not FocoosModel + assert isinstance(result, ModelInfo) + assert result == mock_model_info + + +def test_from_local_dir_success_without_models_dir(mocker: MockerFixture, functional_model_manager, mock_model_info): + # Mock os.path functions + mocker.patch("os.path.exists", return_value=True) + + # Mock ModelInfo.from_json + mocker.patch("focoos.ports.ModelInfo.from_json", return_value=mock_model_info) + + # Call the method + result = functional_model_manager._from_local_dir(name="test-model") + + # Assertions - _from_local_dir returns ModelInfo, not FocoosModel + assert isinstance(result, ModelInfo) + assert result == mock_model_info + + +def test_from_local_dir_with_model_final_pth(mocker: MockerFixture, functional_model_manager, mock_model_info): + # Mock os.path functions + mocker.patch("os.path.exists", return_value=True) + + # Set weights_uri to model_final.pth + mock_model_info.weights_uri = "model_final.pth" + + # Mock ModelInfo.from_json + mocker.patch("focoos.ports.ModelInfo.from_json", return_value=mock_model_info) + + # Call the method + result = functional_model_manager._from_local_dir(name="test-model", models_dir="/path/to/models") + + # Check if weights_uri was updated + expected_weights_path = os.path.join("/path/to/models", "test-model", "model_final.pth") + assert result.weights_uri == expected_weights_path + + +def test_from_local_dir_dir_not_found(mocker: MockerFixture, functional_model_manager): + # Mock os.path.exists to return False (directory not found) + mocker.patch("os.path.exists", return_value=False) + + # Call the method and expect a ValueError + with pytest.raises(ValueError, match="Run test-model not found in /path/to/models"): + functional_model_manager._from_local_dir(name="test-model", models_dir="/path/to/models") + + +def test_from_local_dir_model_info_not_found(mocker: MockerFixture, functional_model_manager): + # Mock os.path.exists to return True for the run_dir but False for the model_info_path + def mock_exists(path): + return not path.endswith("model_info.json") + + mocker.patch("os.path.exists", side_effect=mock_exists) + + # Call the method and expect a ValueError + with pytest.raises(ValueError): + functional_model_manager._from_local_dir(name="test-model", models_dir="/path/to/models") + + +def test_from_hub_success(mocker: MockerFixture, functional_model_manager, mock_model_info): + """Test successful model loading from hub.""" + # Mock hub and dependencies + mock_hub = MagicMock() + mock_hub.download_model_pth.return_value = "/path/to/model.pth" + mock_hub.get_model_info.return_value = MagicMock() + mock_hub.get_model_info.return_value.model_dump.return_value = {} + + # Mock file operations + mocker.patch("os.path.exists", return_value=False) + mocker.patch("focoos.ports.ModelInfo.from_json", side_effect=[mock_model_info, mock_model_info]) + + # Mock ConfigManager + mock_config = MagicMock() + mocker.patch("focoos.model_manager.ConfigManager.from_dict", return_value=mock_config) + + # Mock model_info dump_json method + mock_model_info.dump_json = MagicMock() + + # Call the method + model_info, config = functional_model_manager._from_hub(hub_uri="hub://test/model", hub=mock_hub) + + # Assertions + assert model_info == mock_model_info + assert config == mock_config + mock_hub.download_model_pth.assert_called_once() + mock_hub.get_model_info.assert_called_once() + + +def test_from_hub_invalid_uri(mocker: MockerFixture, functional_model_manager): + """Test hub method with invalid URI.""" + # Mock FocoosHUB to avoid authentication issues + mock_hub = MagicMock() + mocker.patch("focoos.model_manager.FocoosHUB", return_value=mock_hub) + + with pytest.raises(ValueError, match="Model ref is required"): + functional_model_manager._from_hub(hub_uri="hub://") + + +# BackboneManager Tests + + +@pytest.fixture +def mock_backbone_config(): + """Fixture to provide a mock BackboneConfig.""" + from focoos.nn.backbone.base import BackboneConfig + + return MagicMock(spec=BackboneConfig, model_type="resnet") + + +def test_backbone_manager_from_config(mocker: MockerFixture, mock_backbone_config): + """Test that BackboneManager.from_config correctly loads a backbone.""" + from focoos.model_manager import BackboneManager + from focoos.nn.backbone.base import BaseBackbone + + # Mock the get_model_class method + mock_backbone_class = MagicMock(spec=BaseBackbone) + mocker.patch.object(BackboneManager, "get_model_class", return_value=mock_backbone_class) + + # Call the method + result = BackboneManager.from_config(mock_backbone_config) + + # Assertions + BackboneManager.get_model_class.assert_called_once_with(mock_backbone_config.model_type) + mock_backbone_class.assert_called_once_with(mock_backbone_config) + assert result == mock_backbone_class.return_value + + +def test_backbone_manager_from_config_unsupported(mock_backbone_config): + """Test that BackboneManager.from_config raises for unsupported backbone.""" + from focoos.model_manager import BackboneManager + + # Set an unsupported model type + mock_backbone_config.model_type = "unsupported_backbone" + + # Call the method and expect ValueError + with pytest.raises(ValueError, match=f"Backbone {mock_backbone_config.model_type} not supported"): + BackboneManager.from_config(mock_backbone_config) + + +def test_backbone_manager_get_model_class(mocker: MockerFixture): + """Test that BackboneManager.get_model_class correctly imports and returns a class.""" + from focoos.model_manager import BackboneManager + + # Mock importlib.import_module + mock_module = MagicMock() + mock_class = MagicMock() + mock_module.ResNet = mock_class + mocker.patch("importlib.import_module", return_value=mock_module) + + # Call the method + result = BackboneManager.get_model_class("resnet") + + # Assertions + importlib.import_module.assert_called_once_with(".resnet", package="focoos.nn.backbone") + assert result == mock_class + + +# ConfigManager Tests + + +@pytest.fixture +def mock_config_class(): + """Fixture to provide a mock config class.""" + return MagicMock(spec=ModelConfig) + + +def test_config_manager_register_config(): + """Test that ConfigManager.register_config correctly registers a config loader.""" + from focoos.model_manager import ConfigManager + + # Save original mapping for restoration + original_mapping = ConfigManager._MODEL_CFG_MAPPING.copy() + + try: + # Clear the mapping + ConfigManager._MODEL_CFG_MAPPING = {} + + # Create mock loader + mock_loader = MagicMock(return_value=MagicMock(spec=ModelConfig)) + + # Register the loader + ConfigManager.register_config(ModelFamily.DETR, mock_loader) + + # Assertions + assert ModelFamily.DETR.value in ConfigManager._MODEL_CFG_MAPPING + assert ConfigManager._MODEL_CFG_MAPPING[ModelFamily.DETR.value] == mock_loader + finally: + # Restore original mapping + ConfigManager._MODEL_CFG_MAPPING = original_mapping + + +def test_config_manager_from_dict_registered(mocker: MockerFixture, mock_config_class): + """Test ConfigManager.from_dict with a registered config.""" + from focoos.model_manager import ConfigManager + + # Save original mapping for restoration + original_mapping = ConfigManager._MODEL_CFG_MAPPING.copy() + + try: + # Clear the mapping and add our mock + ConfigManager._MODEL_CFG_MAPPING = {ModelFamily.DETR.value: MagicMock(return_value=mock_config_class)} + + # Test data + config_dict = {"num_classes": 10} + + # Mock necessary components + mocker.patch.object(ConfigBackboneManager, "from_dict", return_value=MagicMock()) + + # Call the method + result = ConfigManager.from_dict(ModelFamily.DETR, config_dict) + + # Assertions + assert result == mock_config_class.return_value + mock_config_class.assert_called_once_with(**config_dict) + finally: + # Restore original mapping + ConfigManager._MODEL_CFG_MAPPING = original_mapping + + +def test_config_manager_from_dict_with_backbone_config(mocker: MockerFixture): + """Test ConfigManager.from_dict with a backbone config.""" + from focoos.model_manager import ConfigManager + + # Save original mapping for restoration + original_mapping = ConfigManager._MODEL_CFG_MAPPING.copy() + + try: + + @dataclass + class MockConfig(ModelConfig): + backbone_config: Any + + sub_mock_config_class = MagicMock(spec=MockConfig, __name__="MockConfig", wraps=MockConfig) + sub_mock_config_class.return_value = MagicMock() + + # Clear the mapping and add our mock + ConfigManager._MODEL_CFG_MAPPING = {ModelFamily.DETR.value: MagicMock(return_value=sub_mock_config_class)} + + # Test data with backbone_config + config_dict = {"backbone_config": {"model_type": "resnet"}, "num_classes": 10} + + # Mock ConfigBackboneManager.from_dict + mock_backbone_config = MagicMock() + mocker.patch.object(ConfigBackboneManager, "from_dict", return_value=mock_backbone_config) + + # Call the method + result = ConfigManager.from_dict(ModelFamily.DETR, config_dict) + + # Assertions + ConfigBackboneManager.from_dict.assert_called_once_with({"model_type": "resnet"}) + assert result == sub_mock_config_class.return_value + # Check that backbone_config was properly replaced + sub_mock_config_class.assert_called_once_with(**config_dict) + finally: + # Restore original mapping + ConfigManager._MODEL_CFG_MAPPING = original_mapping + + +def test_config_manager_from_dict_with_kwargs(mocker: MockerFixture, mock_config_class): + """Test ConfigManager.from_dict with kwargs.""" + from focoos.model_manager import ConfigManager + + # Save original mapping for restoration + original_mapping = ConfigManager._MODEL_CFG_MAPPING.copy() + + try: + + @dataclass + class MockConfig(ModelConfig): + param1: str + param2: str + + sub_mock_config_class = MagicMock(spec=MockConfig, __name__="MockConfig", wraps=MockConfig) + + # Clear the mapping and add our mock + ConfigManager._MODEL_CFG_MAPPING = {ModelFamily.DETR.value: MagicMock(return_value=sub_mock_config_class)} + + # Mock update method + mock_config = MagicMock() + sub_mock_config_class.return_value = mock_config + + # Test data + config_dict = {"param1": "value1"} + kwargs = {"param2": "value2"} + + # Call the method + result = ConfigManager.from_dict(ModelFamily.DETR, config_dict, **kwargs) + + # Assertions + assert result == mock_config + mock_config.update.assert_called_once_with({"param2": "value2"}) + finally: + # Restore original mapping + ConfigManager._MODEL_CFG_MAPPING = original_mapping + + +def test_config_manager_from_dict_with_invalid_kwargs(mocker: MockerFixture, mock_config_class): + """Test ConfigManager.from_dict with invalid kwargs.""" + from focoos.model_manager import ConfigManager + + # Save original mapping for restoration + original_mapping = ConfigManager._MODEL_CFG_MAPPING.copy() + + try: + + class MockConfig(ModelConfig): + param1: str + param2: str + + sub_mock_config_class = MagicMock(spec=MockConfig) + sub_mock_config_class.__name__ = "MockConfig" + + # Clear the mapping and add our mock + ConfigManager._MODEL_CFG_MAPPING = {ModelFamily.DETR.value: MagicMock(return_value=sub_mock_config_class)} + + # Mock fields function to return what we need + mock_field = MagicMock() + mock_field.name = "param1" + mocker.patch("dataclasses.fields", return_value=[mock_field]) + + # Test data + config_dict = {"param1": "value1"} + kwargs = {"invalid_param": "value2"} + + # Call the method and expect ValueError + with pytest.raises(ValueError, match="Invalid parameters"): + ConfigManager.from_dict(ModelFamily.DETR, config_dict, **kwargs) + finally: + # Restore original mapping + ConfigManager._MODEL_CFG_MAPPING = original_mapping + + +def test_config_manager_from_dict_unsupported_family(mocker: MockerFixture): + """Test ConfigManager.from_dict with an unsupported family.""" + from focoos.model_manager import ConfigManager + + # Save original mapping for restoration + original_mapping = ConfigManager._MODEL_CFG_MAPPING.copy() + + try: + # Clear the mapping + ConfigManager._MODEL_CFG_MAPPING = {} + + # Mock importlib.import_module to do nothing + mock_module = MagicMock() + # No _register function to be found + mocker.patch("importlib.import_module", return_value=mock_module) + + # Test data + config_dict = {"param1": "value1"} + + # Call the method and expect ValueError + with pytest.raises(ValueError, match=f"Model {ModelFamily.DETR} not supported"): + ConfigManager.from_dict(ModelFamily.DETR, config_dict) + finally: + # Restore original mapping + ConfigManager._MODEL_CFG_MAPPING = original_mapping + + +# ConfigBackboneManager Tests + + +def test_config_backbone_manager_get_model_class(mocker: MockerFixture): + """Test that ConfigBackboneManager.get_model_class correctly imports and returns a class.""" + from focoos.model_manager import ConfigBackboneManager + + # Mock importlib.import_module + mock_module = MagicMock() + mock_class = MagicMock() + mock_module.ResnetConfig = mock_class + mocker.patch("importlib.import_module", return_value=mock_module) + + # Call the method + result = ConfigBackboneManager.get_model_class("resnet") + + # Assertions + importlib.import_module.assert_called_once_with(".resnet", package="focoos.nn.backbone") + assert result == mock_class + + +def test_config_backbone_manager_from_dict(mocker: MockerFixture): + """Test that ConfigBackboneManager.from_dict correctly creates a config.""" + from focoos.model_manager import ConfigBackboneManager + + # Mock data + config_dict = {"model_type": "resnet", "param1": "value1"} + + # Mock get_model_class + mock_config_class = MagicMock() + mock_config = MagicMock() + mock_config_class.return_value = mock_config + mocker.patch.object(ConfigBackboneManager, "get_model_class", return_value=mock_config_class) + + # Call the method + result = ConfigBackboneManager.from_dict(config_dict) + + # Assertions + ConfigBackboneManager.get_model_class.assert_called_once_with(config_dict["model_type"]) + mock_config_class.assert_called_once_with(**config_dict) + assert result == mock_config + + +def test_config_backbone_manager_from_dict_unsupported(mocker: MockerFixture): + """Test that ConfigBackboneManager.from_dict raises for unsupported backbone.""" + from focoos.model_manager import ConfigBackboneManager + + # Test data with unsupported backbone + config_dict = {"model_type": "unsupported_backbone"} + + # Call the method and expect ValueError + with pytest.raises(ValueError, match=f"Backbone {config_dict['model_type']} not supported"): + ConfigBackboneManager.from_dict(config_dict) diff --git a/tests/test_model_registry.py b/tests/test_model_registry.py new file mode 100644 index 00000000..62bb8665 --- /dev/null +++ b/tests/test_model_registry.py @@ -0,0 +1,173 @@ +from unittest.mock import MagicMock, patch + +import pytest + +from focoos.model_registry.model_registry import ModelRegistry +from focoos.ports import ModelFamily, ModelInfo, ModelStatus, Task + + +class TestModelRegistry: + """Test suite for the ModelRegistry class""" + + def test_list_models_returns_all_pretrained_models(self): + """Check that list_models returns all pretrained models""" + expected_models = [ + "fai-detr-l-obj365", + "fai-detr-l-coco", + "fai-detr-m-coco", + "fai-mf-l-ade", + "fai-mf-m-ade", + "fai-mf-l-coco-ins", + "fai-mf-m-coco-ins", + "fai-mf-s-coco-ins", + "bisenetformer-m-ade", + "bisenetformer-l-ade", + "bisenetformer-s-ade", + ] + + models = ModelRegistry.list_models() + + assert isinstance(models, list) + assert len(models) == len(expected_models) + for model in expected_models: + assert model in models + + def test_exists_returns_true_for_valid_pretrained_model(self): + """Check that exists returns True for a valid pretrained model""" + assert ModelRegistry.exists("fai-detr-l-coco") is True + assert ModelRegistry.exists("fai-mf-l-ade") is True + + def test_exists_returns_false_for_invalid_model(self): + """Check that exists returns False for a non-existent model""" + assert ModelRegistry.exists("modello-inesistente") is False + assert ModelRegistry.exists("") is False + assert ModelRegistry.exists("invalid-model-name") is False + + @patch("focoos.ports.ModelInfo.from_json") + def test_get_model_info_with_pretrained_model(self, mock_from_json: MagicMock): + """Check that get_model_info correctly loads a pretrained model""" + # Setup mock ModelInfo + mock_model_info = ModelInfo( + name="fai-detr-l-coco", + model_family=ModelFamily.DETR, + classes=["person", "car", "dog"], + im_size=640, + task=Task.DETECTION, + config={"num_classes": 3}, + status=ModelStatus.TRAINING_COMPLETED, + ) + mock_from_json.return_value = mock_model_info + + # Test + result = ModelRegistry.get_model_info("fai-detr-l-coco") + + # Verification + assert result == mock_model_info + mock_from_json.assert_called_once() + # Check that it was called with the correct path + call_args = mock_from_json.call_args[0][0] + assert call_args.endswith("fai-detr-l-coco.json") + + @patch("os.path.exists") + @patch("focoos.ports.ModelInfo.from_json") + def test_get_model_info_with_custom_model_path(self, mock_from_json: MagicMock, mock_exists: MagicMock): + """Check that get_model_info loads a model from a custom path""" + # Setup + custom_path = "/path/to/custom_model.json" + mock_exists.return_value = True + mock_model_info = ModelInfo( + name="custom-model", + model_family=ModelFamily.DETR, + classes=["class1", "class2"], + im_size=512, + task=Task.DETECTION, + config={"num_classes": 2}, + ) + mock_from_json.return_value = mock_model_info + + # Test + result = ModelRegistry.get_model_info(custom_path) + + # Verification + assert result == mock_model_info + mock_exists.assert_called_once_with(custom_path) + mock_from_json.assert_called_once_with(custom_path) + + @patch("os.path.exists") + def test_get_model_info_raises_error_for_nonexistent_custom_path(self, mock_exists: MagicMock): + """Check that get_model_info raises an error for a non-existent custom path""" + # Setup + custom_path = "/path/to/nonexistent_model.json" + mock_exists.return_value = False + + # Test and verification + with pytest.raises(ValueError, match=f"โš ๏ธ Model {custom_path} not found"): + ModelRegistry.get_model_info(custom_path) + + mock_exists.assert_called_once_with(custom_path) + + def test_get_model_info_raises_error_for_nonexistent_pretrained_model(self): + """Check that get_model_info raises an error for a non-existent pretrained model""" + nonexistent_model = "modello-inesistente" + + with pytest.raises(ValueError, match=f"โš ๏ธ Model {nonexistent_model} not found"): + ModelRegistry.get_model_info(nonexistent_model) + + @patch("focoos.ports.ModelInfo.from_json") + def test_get_model_info_handles_json_loading_error(self, mock_from_json: MagicMock): + """Check that get_model_info correctly handles JSON loading errors""" + # Setup + mock_from_json.side_effect = FileNotFoundError("File not found") + + # Test and verification + with pytest.raises(FileNotFoundError): + ModelRegistry.get_model_info("fai-detr-l-coco") + + def test_pretrained_models_dictionary_structure(self): + """Check that the _pretrained_models dictionary has the correct structure""" + pretrained_models = ModelRegistry._pretrained_models + + assert isinstance(pretrained_models, dict) + assert len(pretrained_models) > 0 + + for model_name, model_path in pretrained_models.items(): + # Check that the model name is a non-empty string + assert isinstance(model_name, str) + assert len(model_name) > 0 + + # Check that the path is a string ending with .json + assert isinstance(model_path, str) + assert model_path.endswith(".json") + assert model_name in model_path + + @pytest.mark.parametrize( + "model_name,expected_exists", + [ + ("fai-detr-l-coco", True), + ("fai-detr-m-coco", True), + ("fai-mf-l-ade", True), + ("bisenetformer-s-ade", True), + ("nonexistent-model", False), + ("", False), + ("fai-detr-xl-coco", False), # Non-existent variant + ], + ) + def test_exists_parametrized(self, model_name: str, expected_exists: bool): + """Parametrized test to check the existence of various models""" + assert ModelRegistry.exists(model_name) == expected_exists + + def test_registry_path_configuration(self): + """Check that REGISTRY_PATH is correctly configured""" + from focoos.model_registry.model_registry import REGISTRY_PATH + + assert isinstance(REGISTRY_PATH, str) + assert len(REGISTRY_PATH) > 0 + assert "model_registry" in REGISTRY_PATH + + @patch("focoos.utils.logger.get_logger") + def test_logger_warning_on_model_not_found(self, mock_get_logger: MagicMock): + """Check that a warning is logged when a model is not found""" + nonexistent_model = "modello-inesistente" + + with pytest.raises(ValueError): + ModelRegistry.get_model_info(nonexistent_model) diff --git a/tests/test_ports.py b/tests/test_ports.py index 559857f6..ba7dd592 100644 --- a/tests/test_ports.py +++ b/tests/test_ports.py @@ -1,38 +1,227 @@ +from dataclasses import dataclass +from typing import Optional + import pytest -from pydantic import ValidationError from pytest_mock import MockerFixture from focoos.ports import ( + DictClass, GPUDevice, GPUInfo, - Hyperparameters, - ModelFormat, - RuntimeTypes, + ModelExtension, + RuntimeType, SystemInfo, ) -def test_validate_wandb_project_valid(): - wandb_project = "randomname" - params = Hyperparameters(wandb_project=wandb_project) - assert params.wandb_project == wandb_project +@dataclass +class DictDataclassTest(DictClass): + """Test dataclass to verify DictClass behavior""" + name: str + value: int + optional_field: Optional[str] = None + default_field: str = "default" -def test_validate_wandb_project_invalid(): - # Invalid wandb_project values - invalid_values = [ - "ORG ID/PROJECT NAME", # Spaces are not allowed - "ORG@ID/PROJECT#NAME", # Special characters are not allowed - "ORG/PROJECT:NAME", # Special characters are not allowed - ] - for value in invalid_values: - with pytest.raises(ValidationError) as exc_info: - Hyperparameters(wandb_project=value) - assert "Wandb project name must only contain characters, dashes, underscores, and dots." in str(exc_info.value) + +def test_dataclass_initialization_with_required_fields(): + """Check that a dataclass inheriting from DictClass initializes correctly with required fields""" + test_obj = DictDataclassTest(name="test", value=42) + + assert test_obj.name == "test" + assert test_obj.value == 42 + assert test_obj.optional_field is None + assert test_obj.default_field == "default" + + +def test_dataclass_initialization_with_all_fields(): + """Check that a dataclass initializes with all specified fields""" + test_obj = DictDataclassTest(name="complete_test", value=100, optional_field="optional", default_field="custom") + + assert test_obj.name == "complete_test" + assert test_obj.value == 100 + assert test_obj.optional_field == "optional" + assert test_obj.default_field == "custom" + + +def test_dict_like_access_with_string_keys(): + """Check that the object can be used as a dictionary with string keys""" + test_obj = DictDataclassTest(name="dict_test", value=123) + + assert test_obj["name"] == "dict_test" + assert test_obj["value"] == 123 + assert test_obj["optional_field"] is None + assert test_obj["default_field"] == "default" + + +def test_dict_like_access_with_integer_indices(): + """Check that the object supports access via integer indices""" + test_obj = DictDataclassTest(name="index_test", value=456) + tuple_representation = test_obj.to_tuple() + + # Check that access by index returns tuple elements + assert len(tuple_representation) > 0 + assert test_obj[0] == tuple_representation[0] + if len(tuple_representation) > 1: + assert test_obj[1] == tuple_representation[1] + + +def test_to_tuple_method(): + """Check that the to_tuple method returns a tuple with all non-None values""" + test_obj = DictDataclassTest(name="tuple_test", value=789, optional_field="present") + result_tuple = test_obj.to_tuple() + + assert isinstance(result_tuple, tuple) + assert len(result_tuple) == 4 # name, value, optional_field, default_field + assert "tuple_test" in result_tuple + assert 789 in result_tuple + assert "present" in result_tuple + assert "default" in result_tuple + + +def test_to_tuple_with_none_values(): + """Check that to_tuple does not include None values""" + test_obj = DictDataclassTest(name="none_test", value=0, optional_field=None) + result_tuple = test_obj.to_tuple() + + assert isinstance(result_tuple, tuple) + assert "none_test" in result_tuple + assert 0 in result_tuple + assert None not in result_tuple + assert "default" in result_tuple + + +def test_setattr_updates_dict_and_attribute(): + """Check that __setattr__ updates both the attribute and the dictionary entry""" + test_obj = DictDataclassTest(name="setattr_test", value=111) + + # Modify an existing value + test_obj.name = "updated_name" + assert test_obj.name == "updated_name" + assert test_obj["name"] == "updated_name" + + # Modify an optional value + test_obj.optional_field = "new_optional" + assert test_obj.optional_field == "new_optional" + assert test_obj["optional_field"] == "new_optional" + + +def test_setitem_updates_dict_and_attribute(): + """Check that __setitem__ updates both the dictionary entry and the attribute""" + test_obj = DictDataclassTest(name="setitem_test", value=222) + + # Modify via dictionary access + test_obj["name"] = "dict_updated_name" + assert test_obj.name == "dict_updated_name" + assert test_obj["name"] == "dict_updated_name" + + test_obj["value"] = 999 + assert test_obj.value == 999 + assert test_obj["value"] == 999 + + +def test_dictionary_behavior_inheritance(): + """Check that the object behaves like an OrderedDict""" + test_obj = DictDataclassTest(name="dict_behavior", value=333) + + # Test keys(), values(), items() + keys = list(test_obj.keys()) + values = list(test_obj.values()) + items = list(test_obj.items()) + + assert "name" in keys + assert "value" in keys + assert "dict_behavior" in values + assert 333 in values + assert ("name", "dict_behavior") in items + assert ("value", 333) in items + + +def test_post_init_populates_dict_from_dataclass_fields(): + """Check that __post_init__ correctly populates the dictionary from dataclass fields""" + test_obj = DictDataclassTest(name="post_init_test", value=444) + + # Check that all fields are present in the dictionary + expected_fields = ["name", "value", "optional_field", "default_field"] + for field in expected_fields: + assert field in test_obj + + # Check that values match + assert test_obj["name"] == "post_init_test" + assert test_obj["value"] == 444 + assert test_obj["optional_field"] is None + assert test_obj["default_field"] == "default" + + +def test_reduce_method_for_serialization(): + """Check that __reduce__ allows correct serialization of the object""" + test_obj = DictDataclassTest(name="reduce_test", value=555) + + # Test the __reduce__ method + reduce_result = test_obj.__reduce__() + + assert isinstance(reduce_result, tuple) + assert len(reduce_result) == 3 + + constructor, args, state = reduce_result + assert constructor == DictDataclassTest.__new__ + assert args == (DictDataclassTest,) + assert isinstance(state, dict) + assert "name" in state + assert "value" in state + + +def test_getitem_with_invalid_key_raises_error(): + """Check that access with a non-existent key raises an exception""" + test_obj = DictDataclassTest(name="error_test", value=666) + + with pytest.raises(KeyError): + _ = test_obj["nonexistent_key"] + + +def test_getitem_with_invalid_index_raises_error(): + """Check that access with an invalid index raises an exception""" + test_obj = DictDataclassTest(name="index_error_test", value=777) + + with pytest.raises(IndexError): + _ = test_obj[10] # Out of range index + + +@pytest.mark.parametrize( + "name,value,optional_field,expected_length", + [ + ("test1", 1, None, 4), + ("test2", 2, "optional", 4), + ("test3", 3, "another", 4), + ], +) +def test_parametrized_dict_creation(name: str, value: int, optional_field: Optional[str], expected_length: int): + """Parametrized test to verify creation of DictClass objects with different parameters""" + test_obj = DictDataclassTest(name=name, value=value, optional_field=optional_field) + + assert len(test_obj) == expected_length + assert test_obj.name == name + assert test_obj.value == value + assert test_obj.optional_field == optional_field + + +def test_none_value_handling_in_setattr(): + """Check that __setattr__ correctly handles None values""" + test_obj = DictDataclassTest(name="none_handling", value=888) + + # Set a value to None + test_obj.optional_field = None + assert test_obj.optional_field is None + assert test_obj["optional_field"] is None + + # Set the value back to a non-None value + test_obj.optional_field = "not_none_anymore" + assert test_obj.optional_field == "not_none_anymore" + assert test_obj["optional_field"] == "not_none_anymore" def test_pretty_print_with_system_info(mocker: MockerFixture): - """Verifica che pretty_print formatti correttamente tutte le informazioni di sistema""" + """Check that pretty_print correctly formats all system information""" gpu_devices = [ GPUDevice( @@ -54,7 +243,7 @@ def test_pretty_print_with_system_info(mocker: MockerFixture): cpu_cores=8, memory_gb=16.0, memory_used_percentage=50.0, - available_providers=["provider1", "provider2"], + available_onnx_providers=["provider1", "provider2"], disk_space_total_gb=500.0, disk_space_used_percentage=60.0, packages_versions={"pytest": "6.2.4", "pydantic": "1.8.2"}, @@ -62,69 +251,26 @@ def test_pretty_print_with_system_info(mocker: MockerFixture): environment={"FOCOOS_LOG_LEVEL": "DEBUG", "LD_LIBRARY_PATH": "/usr/local/cuda/lib64"}, ) - mock_print = mocker.patch("builtins.print") - - expected_calls = [ - "================ SYSTEM INFO ====================", - "focoos_host: localhost", - "system: Linux", - "system_name: TestSystem", - "cpu_type: Intel", - "cpu_cores: 8", - "memory_gb: 16.0", - "memory_used_percentage: 50.0", - "available_providers:", - " - provider1", - " - provider2", - "disk_space_total_gb: 500.0", - "disk_space_used_percentage: 60.0", - "gpu_info:", - " - gpu_count: 1", - " - gpu_driver: NVIDIA", - " - gpu_cuda_version: 11.2", - " - devices:", - " - GPU 0:", - " - gpu_name: NVIDIA GTX 1080", - " - gpu_memory_total_gb: 8.0", - " - gpu_memory_used_percentage: 70.0", - " - gpu_temperature: 65.0", - " - gpu_load_percentage: 80.0", - "packages_versions:", - " - pytest: 6.2.4", - " - pydantic: 1.8.2", - "environment:", - " - FOCOOS_LOG_LEVEL: DEBUG", - " - LD_LIBRARY_PATH: /usr/local/cuda/lib64", - "================================================", - ] - - system_info.pretty_print() - - # Verifica che tutte le chiamate attese siano state effettuate - for call in expected_calls: - mock_print.assert_any_call(call) - - # Verifica che il numero totale di chiamate sia corretto - assert mock_print.call_count == len(expected_calls) + system_info.pprint() @pytest.mark.parametrize( "runtime_type,expected_format", [ - (RuntimeTypes.ONNX_CUDA32, ModelFormat.ONNX), - (RuntimeTypes.ONNX_TRT32, ModelFormat.ONNX), - (RuntimeTypes.ONNX_TRT16, ModelFormat.ONNX), - (RuntimeTypes.ONNX_CPU, ModelFormat.ONNX), - (RuntimeTypes.ONNX_COREML, ModelFormat.ONNX), - (RuntimeTypes.TORCHSCRIPT_32, ModelFormat.TORCHSCRIPT), + (RuntimeType.ONNX_CUDA32, ModelExtension.ONNX), + (RuntimeType.ONNX_TRT32, ModelExtension.ONNX), + (RuntimeType.ONNX_TRT16, ModelExtension.ONNX), + (RuntimeType.ONNX_CPU, ModelExtension.ONNX), + (RuntimeType.ONNX_COREML, ModelExtension.ONNX), + (RuntimeType.TORCHSCRIPT_32, ModelExtension.TORCHSCRIPT), ], ) def test_model_format_from_runtime_type(runtime_type, expected_format): """Test that from_runtime_type returns correct ModelFormat for each RuntimeType""" - assert ModelFormat.from_runtime_type(runtime_type) == expected_format + assert ModelExtension.from_runtime_type(runtime_type) == expected_format def test_model_format_from_runtime_type_invalid(): """Test that from_runtime_type raises ValueError for invalid runtime type""" with pytest.raises(ValueError, match="Invalid runtime type:.*"): - ModelFormat.from_runtime_type("invalid_runtime") + ModelExtension.from_runtime_type("invalid_runtime") # type: ignore diff --git a/tests/test_remote_dataset.py b/tests/test_remote_dataset.py index cba5857f..4a5eb551 100644 --- a/tests/test_remote_dataset.py +++ b/tests/test_remote_dataset.py @@ -2,14 +2,15 @@ import pytest -from focoos.ports import DatasetLayout, DatasetPreview, FocoosTask -from focoos.remote_dataset import RemoteDataset +from focoos.hub.api_client import ApiClient +from focoos.hub.remote_dataset import RemoteDataset +from focoos.ports import DatasetLayout, DatasetPreview, Task @pytest.fixture def mock_api_client(): """Fixture to create a mock ApiClient.""" - client = Mock() + client = Mock(spec=ApiClient) return client @@ -20,7 +21,7 @@ def dataset_preview_data(): "ref": "test-dataset", "name": "Test Dataset", "layout": DatasetLayout.ROBOFLOW_COCO, - "task": FocoosTask.DETECTION, + "task": Task.DETECTION, "description": "Test dataset description", "spec": {"train_length": 100, "valid_length": 20, "size_mb": 256.0}, } @@ -29,7 +30,12 @@ def dataset_preview_data(): @pytest.fixture def remote_dataset(mock_api_client, dataset_preview_data): """Fixture to create a RemoteDataset instance with a mock ApiClient.""" - mock_api_client.get.return_value.json.return_value = dataset_preview_data + # Mock the API response correctly + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = dataset_preview_data + mock_api_client.get.return_value = mock_response + return RemoteDataset("test-dataset", mock_api_client) @@ -41,36 +47,33 @@ def test_init_and_get_info(remote_dataset, mock_api_client, dataset_preview_data assert remote_dataset.metadata.name == dataset_preview_data["name"] assert remote_dataset.metadata.layout == dataset_preview_data["layout"] assert remote_dataset.metadata.task == dataset_preview_data["task"] - mock_api_client.get.assert_called_once_with("datasets/test-dataset") + mock_api_client.get.assert_called_with("datasets/test-dataset") -def test_delete_success(remote_dataset, mock_api_client): - """Test successful dataset deletion.""" - mock_api_client.delete.return_value.raise_for_status.return_value = None - remote_dataset.delete() - mock_api_client.delete.assert_called_once_with("datasets/test-dataset") +def test_get_info_failure(mock_api_client): + """Test get_info method failure.""" + mock_response = Mock() + mock_response.status_code = 404 + mock_response.text = "Not found" + mock_api_client.get.return_value = mock_response + with pytest.raises(ValueError, match="Failed to get dataset info"): + RemoteDataset("nonexistent-dataset", mock_api_client) -def test_delete_failure(remote_dataset, mock_api_client): - """Test error handling during deletion.""" - mock_api_client.delete.side_effect = Exception("Delete failed") - with pytest.raises(Exception, match="Delete failed"): - remote_dataset.delete() +def test_properties(remote_dataset, dataset_preview_data): + """Test dataset properties.""" + assert remote_dataset.name == dataset_preview_data["name"] + assert remote_dataset.task == dataset_preview_data["task"] + assert remote_dataset.layout == dataset_preview_data["layout"] -def test_delete_data_success(remote_dataset, mock_api_client, dataset_preview_data): - """Test successful data deletion.""" - # Modify data to simulate a dataset without spec after deletion - updated_preview = dataset_preview_data.copy() - updated_preview["spec"] = None - mock_response = Mock() - mock_response.json.return_value = updated_preview - mock_api_client.delete.return_value = mock_response - - remote_dataset.delete_data() - mock_api_client.delete.assert_called_once_with("datasets/test-dataset/data") - assert remote_dataset.metadata.spec is None +def test_str_representation(remote_dataset): + """Test string representation.""" + str_repr = str(remote_dataset) + assert "RemoteDataset" in str_repr + assert "test-dataset" in str_repr + assert "Test Dataset" in str_repr @pytest.mark.parametrize( @@ -97,14 +100,104 @@ def test_upload_data_success( mock_getsize.return_value = 1024 # Mock for upload URL generation - mock_api_client.post.side_effect = [ - Mock(status_code=200, json=lambda: {"url": "https://test-url", "fields": {"key": "value"}}), - Mock(status_code=422, text="Invalid data"), - ] - mock_api_client.external_post.return_value = Mock(status_code=200) + mock_upload_response = Mock() + mock_upload_response.status_code = 200 + mock_upload_response.json.return_value = {"url": "https://test-url", "fields": {"key": "value"}} + + # Mock for upload completion + mock_complete_response = Mock() + mock_complete_response.status_code = 200 + + # Mock for external post (file upload) + mock_external_post_response = Mock() + mock_external_post_response.status_code = 200 + + # Mock updated dataset info + updated_data = dataset_preview_data.copy() + updated_data["spec"] = {"train_length": 150, "valid_length": 30, "size_mb": 512.0} + mock_info_response = Mock() + mock_info_response.status_code = 200 + mock_info_response.json.return_value = updated_data + + mock_api_client.post.side_effect = [mock_upload_response, mock_complete_response] + mock_api_client.external_post.return_value = mock_external_post_response + mock_api_client.get.return_value = mock_info_response + + result = remote_dataset.upload_data("test.zip") + + assert result is not None + assert result.train_length == 150 + assert result.valid_length == 30 + + # Verify API calls + assert mock_api_client.post.call_count == 2 + mock_api_client.external_post.assert_called_once() + + +@patch("os.path.exists") +@patch("os.path.getsize") +def test_upload_data_upload_url_failure(mock_getsize, mock_exists, remote_dataset, mock_api_client): + """Test upload data when URL generation fails.""" + mock_exists.return_value = True + mock_getsize.return_value = 1024 + + mock_response = Mock() + mock_response.status_code = 500 + mock_response.text = "Server error" + mock_api_client.post.return_value = mock_response + + with pytest.raises(ValueError, match="Failed to generate upload url"): + remote_dataset.upload_data("test.zip") + + +@patch("os.path.exists") +@patch("os.path.getsize") +@patch("builtins.open", new_callable=mock_open, read_data="test data") +def test_upload_data_external_upload_failure(mock_file, mock_getsize, mock_exists, remote_dataset, mock_api_client): + """Test upload data when external upload fails.""" + mock_exists.return_value = True + mock_getsize.return_value = 1024 + + # Mock successful URL generation + mock_upload_response = Mock() + mock_upload_response.status_code = 200 + mock_upload_response.json.return_value = {"url": "https://test-url", "fields": {"key": "value"}} + + # Mock failed external upload + mock_external_response = Mock() + mock_external_response.status_code = 500 + mock_external_response.text = "Upload failed" + + mock_api_client.post.return_value = mock_upload_response + mock_api_client.external_post.return_value = mock_external_response + + with pytest.raises(ValueError, match="Failed to upload dataset"): + remote_dataset.upload_data("test.zip") + + +@patch("os.path.exists") +@patch("os.path.getsize") +@patch("builtins.open", new_callable=mock_open, read_data="test data") +def test_upload_data_validation_failure(mock_file, mock_getsize, mock_exists, remote_dataset, mock_api_client): + """Test upload data when validation fails.""" + mock_exists.return_value = True + mock_getsize.return_value = 1024 + + # Mock successful URL generation and upload + mock_upload_response = Mock() + mock_upload_response.status_code = 200 + mock_upload_response.json.return_value = {"url": "https://test-url", "fields": {"key": "value"}} - # Mock for dataset info update - mock_api_client.get.return_value.json.return_value = dataset_preview_data + mock_external_response = Mock() + mock_external_response.status_code = 200 + + # Mock failed validation + mock_validation_response = Mock() + mock_validation_response.status_code = 422 + mock_validation_response.text = "Invalid data" + + mock_api_client.post.side_effect = [mock_upload_response, mock_validation_response] + mock_api_client.external_post.return_value = mock_external_response with pytest.raises(ValueError, match="Failed to validate dataset"): remote_dataset.upload_data("test.zip") @@ -113,20 +206,51 @@ def test_upload_data_success( def test_download_data_success(remote_dataset, mock_api_client): """Test successful data download.""" # Reset previous mock calls - mock_api_client.get.reset_mock() - mock_api_client.get.return_value = Mock(status_code=200, json=lambda: {"download_uri": "https://test-download-url"}) - mock_api_client.download_file.return_value = "/local/path/dataset.zip" + mock_api_client.reset_mock() + + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = {"download_uri": "https://test-download-url"} + mock_api_client.get.return_value = mock_response + + # Mock download_ext_file method + mock_api_client.download_ext_file.return_value = "/local/path/dataset.zip" result = remote_dataset.download_data("/local/path") assert result == "/local/path/dataset.zip" mock_api_client.get.assert_called_once_with("datasets/test-dataset/download") - mock_api_client.download_file.assert_called_once_with("https://test-download-url", "/local/path") + mock_api_client.download_ext_file.assert_called_once_with( + "https://test-download-url", "/local/path", skip_if_exists=True + ) def test_download_data_failure(remote_dataset, mock_api_client): """Test error handling during download.""" - mock_api_client.get.return_value = Mock(status_code=404, text="Not found") + mock_api_client.reset_mock() + + mock_response = Mock() + mock_response.status_code = 404 + mock_response.text = "Not found" + mock_api_client.get.return_value = mock_response with pytest.raises(ValueError, match="Failed to download dataset data"): remote_dataset.download_data("/local/path") + + +def test_download_data_default_path(remote_dataset, mock_api_client): + """Test download with default path.""" + mock_api_client.reset_mock() + + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = {"download_uri": "https://test-download-url"} + mock_api_client.get.return_value = mock_response + + mock_api_client.download_ext_file.return_value = "/default/path/dataset.zip" + + result = remote_dataset.download_data() + + assert result == "/default/path/dataset.zip" + # Should use the default DATASETS_DIR + mock_api_client.download_ext_file.assert_called_once() diff --git a/tests/test_remote_model.py b/tests/test_remote_model.py index 525ed112..a3f4c2b1 100644 --- a/tests/test_remote_model.py +++ b/tests/test_remote_model.py @@ -1,35 +1,22 @@ from unittest.mock import MagicMock -import numpy as np import pytest from pytest_mock import MockerFixture import tests -from focoos.ports import FocoosTask, Hyperparameters, Metrics, ModelMetadata, ModelStatus, TrainingInfo, TrainInstance -from focoos.remote_model import RemoteModel +from focoos.hub.remote_model import RemoteModel +from focoos.ports import Metrics, ModelStatus, RemoteModelInfo, Task, TrainingInfo -def _get_mock_remote_model(mocker: MockerFixture, mock_api_client, image_ndarray, mock_metadata: ModelMetadata): +def _get_mock_remote_model(mocker: MockerFixture, mock_api_client, image_ndarray, mock_metadata: RemoteModelInfo): mock_api_client.get = MagicMock(return_value=MagicMock(status_code=200, json=lambda: mock_metadata.model_dump())) model = RemoteModel(model_ref="test_model_ref", api_client=mock_api_client) - # Mock BoxAnnotator - mock_box_annotator = mocker.patch("focoos.remote_model.sv.BoxAnnotator", autospec=True) - mock_box_annotator.annotate = MagicMock(return_value=np.zeros_like(image_ndarray)) - - # Mock LabelAnnotator - mock_label_annotator = mocker.patch("focoos.remote_model.sv.LabelAnnotator", autospec=True) - mock_label_annotator.annotate = MagicMock(return_value=np.zeros_like(image_ndarray)) - - # Mock MaskAnnotator - mock_mask_annotator = mocker.patch("focoos.remote_model.sv.MaskAnnotator", autospec=True) - mock_mask_annotator.annotate = MagicMock(return_value=np.zeros_like(image_ndarray)) - return model @pytest.fixture -def mock_remote_model(mocker: MockerFixture, mock_api_client, image_ndarray, mock_metadata: ModelMetadata): +def mock_remote_model(mocker: MockerFixture, mock_api_client, image_ndarray, mock_metadata: RemoteModelInfo): return _get_mock_remote_model( mocker=mocker, mock_api_client=mock_api_client, @@ -45,7 +32,7 @@ def test_remote_model_initialization_fail_to_fetch_model_info(mock_api_client): def test_remote_model_initialization_ok( - mocker: MockerFixture, mock_api_client, image_ndarray, mock_metadata: ModelMetadata + mocker: MockerFixture, mock_api_client, image_ndarray, mock_metadata: RemoteModelInfo ): with tests.not_raises(Exception): _get_mock_remote_model( @@ -99,30 +86,6 @@ def test_train_logs_ok(mock_remote_model: RemoteModel): assert result == ["log1", "log2"] -def test_stop_training_fail(mock_remote_model: RemoteModel): - with pytest.raises(ValueError): - mock_remote_model.api_client.delete = MagicMock(return_value=MagicMock(status_code=500)) - mock_remote_model.stop_training() - - -def test_stop_training_ok(mock_remote_model: RemoteModel): - with tests.not_raises(Exception): - mock_remote_model.api_client.delete = MagicMock(return_value=MagicMock(status_code=200)) - mock_remote_model.stop_training() - - -def test_delete_model_fail(mock_remote_model: RemoteModel): - with pytest.raises(ValueError): - mock_remote_model.api_client.delete = MagicMock(return_value=MagicMock(status_code=500)) - mock_remote_model.delete_model() - - -def test_delete_model_ok(mock_remote_model: RemoteModel): - with tests.not_raises(Exception): - mock_remote_model.api_client.delete = MagicMock(return_value=MagicMock(status_code=204)) - mock_remote_model.delete_model() - - def test_train_metrics_fail(mock_remote_model: RemoteModel): mock_remote_model.api_client.get = MagicMock(return_value=MagicMock(status_code=500, text="Internal Server Error")) result = mock_remote_model.metrics() @@ -151,43 +114,6 @@ def mock_hyperparameters(mocker: MockerFixture): ) -def test_train_fail( - mock_remote_model: RemoteModel, - mock_hyperparameters: Hyperparameters, -): - with pytest.raises(ValueError): - mock_remote_model.api_client.post = MagicMock(return_value=MagicMock(status_code=500)) - mock_remote_model.train( - dataset_ref="dataset_123", - hyperparameters=mock_hyperparameters, - instance_type=TrainInstance.ML_G4DN_XLARGE, - volume_size=50, - max_runtime_in_seconds=36000, - ) - - -def test_train_ok(mock_remote_model: RemoteModel, mock_hyperparameters: Hyperparameters): - mock_remote_model.api_client.post = MagicMock( - return_value=MagicMock( - status_code=200, - json=MagicMock( - return_value={ - "status": "training started", - "model_ref": "model_123", - } - ), - ) - ) - result = mock_remote_model.train( - dataset_ref="dataset_123", - hyperparameters=mock_hyperparameters, - instance_type=TrainInstance.ML_G4DN_XLARGE, - volume_size=50, - max_runtime_in_seconds=36000, - ) - assert result == {"status": "training started", "model_ref": "model_123"} - - def test_metrics_semseg(mock_remote_model: RemoteModel, mocker): mocker.patch.object( mock_remote_model, @@ -198,7 +124,7 @@ def test_metrics_semseg(mock_remote_model: RemoteModel, mocker): best_valid_metric={"iteration": 3, "loss": 0.1, "sem_seg/mIoU": 0.95}, ), ) - mock_remote_model.metadata.task = FocoosTask.SEMSEG + mock_remote_model.metadata.task = Task.SEMSEG metrics = mock_remote_model.metrics() assert isinstance(metrics, Metrics) @@ -218,7 +144,7 @@ def test_metrics_detection(mock_remote_model: RemoteModel, mocker): best_valid_metric={"iteration": 1, "loss": 0.4, "bbox/AP50": 0.82}, ), ) - mock_remote_model.metadata.task = FocoosTask.DETECTION + mock_remote_model.metadata.task = Task.DETECTION metrics = mock_remote_model.metrics() assert metrics.best_valid_metric == {"iteration": 1, "loss": 0.4, "bbox/AP50": 0.82} @@ -270,7 +196,7 @@ def test_notebook_monitor_train_running(mock_remote_model: RemoteModel, mocker): "time.time", side_effect=[1000 + i * 30 for i in range(10)], # Provide enough values for all time.time() calls ) - mock_sleep = mocker.patch("focoos.remote_model.sleep") + mock_sleep = mocker.patch("focoos.hub.remote_model.sleep") # mock_time = mocker.patch("focoos.remote_model.time") mock_clear = mocker.patch("IPython.display.clear_output") @@ -309,7 +235,7 @@ def test_notebook_monitor_train_max_runtime(mock_remote_model: RemoteModel, mock """Test that monitoring stops when max runtime is exceeded.""" # Mock time module mocker.patch( - "focoos.remote_model.time", + "focoos.hub.remote_model.time", **{ "time": mocker.Mock(side_effect=[1000, 40000]), # First call for start_time, second for check "sleep": mocker.Mock(), # Prevent actual sleeping diff --git a/tests/test_runtime.py b/tests/test_runtime.py index 60df09e9..0addaf0d 100644 --- a/tests/test_runtime.py +++ b/tests/test_runtime.py @@ -4,14 +4,10 @@ import pytest from pytest_mock import MockerFixture -from focoos.ports import ModelMetadata, OnnxRuntimeOpts, RuntimeTypes, TorchscriptRuntimeOpts -from focoos.runtime import ( - ORT_AVAILABLE, - TORCH_AVAILABLE, - ONNXRuntime, - TorchscriptRuntime, - load_runtime, -) +from focoos.infer.runtimes.load_runtime import ORT_AVAILABLE, TORCH_AVAILABLE, load_runtime +from focoos.infer.runtimes.onnx import ONNXRuntime +from focoos.infer.runtimes.torchscript import TorchscriptRuntime +from focoos.ports import ModelInfo, OnnxRuntimeOpts, RuntimeType, TorchscriptRuntimeOpts def test_runtime_availability(): @@ -54,7 +50,7 @@ def test_onnx_import(): "runtime_type, expected_opts", [ ( - RuntimeTypes.ONNX_CUDA32, + RuntimeType.ONNX_CUDA32, OnnxRuntimeOpts( cuda=True, trt=False, @@ -65,7 +61,7 @@ def test_onnx_import(): ), ), ( - RuntimeTypes.ONNX_TRT32, + RuntimeType.ONNX_TRT32, OnnxRuntimeOpts( cuda=False, trt=True, @@ -76,7 +72,7 @@ def test_onnx_import(): ), ), ( - RuntimeTypes.ONNX_TRT16, + RuntimeType.ONNX_TRT16, OnnxRuntimeOpts( cuda=False, trt=True, @@ -87,7 +83,7 @@ def test_onnx_import(): ), ), ( - RuntimeTypes.ONNX_CPU, + RuntimeType.ONNX_CPU, OnnxRuntimeOpts( cuda=False, trt=False, @@ -98,7 +94,7 @@ def test_onnx_import(): ), ), ( - RuntimeTypes.ONNX_COREML, + RuntimeType.ONNX_COREML, OnnxRuntimeOpts( cuda=False, trt=False, @@ -109,7 +105,7 @@ def test_onnx_import(): ), ), ( - RuntimeTypes.TORCHSCRIPT_32, + RuntimeType.TORCHSCRIPT_32, TorchscriptRuntimeOpts( warmup_iter=2, optimize_for_inference=True, @@ -126,16 +122,16 @@ def test_load_runtime(mocker: MockerFixture, tmp_path, runtime_type, expected_op model_path = model_path.as_posix() # mock model metadata - mock_model_metadata = MagicMock(spec=ModelMetadata) + mock_model_metadata = MagicMock(spec=ModelInfo) # mock opts - if runtime_type == RuntimeTypes.TORCHSCRIPT_32: - mocker.patch("focoos.runtime.TORCH_AVAILABLE", True) - mock_runtime_class = mocker.patch("focoos.runtime.TorchscriptRuntime", autospec=True) + if runtime_type == RuntimeType.TORCHSCRIPT_32: + mocker.patch("focoos.infer.runtimes.load_runtime.TORCH_AVAILABLE", True) + mock_runtime_class = mocker.patch("focoos.infer.runtimes.torchscript.TorchscriptRuntime", autospec=True) mock_runtime_class.return_value = MagicMock(spec=TorchscriptRuntime, opts=expected_opts) else: - mocker.patch("focoos.runtime.ORT_AVAILABLE", True) - mock_runtime_class = mocker.patch("focoos.runtime.ONNXRuntime", autospec=True) + mocker.patch("focoos.infer.runtimes.load_runtime.ORT_AVAILABLE", True) + mock_runtime_class = mocker.patch("focoos.infer.runtimes.onnx.ONNXRuntime", autospec=True) mock_runtime_class.return_value = MagicMock(spec=ONNXRuntime, opts=expected_opts) # warmup_iter @@ -145,7 +141,7 @@ def test_load_runtime(mocker: MockerFixture, tmp_path, runtime_type, expected_op runtime = load_runtime( runtime_type=runtime_type, model_path=model_path, - model_metadata=mock_model_metadata, + model_info=mock_model_metadata, warmup_iter=warmup_iter, ) @@ -159,9 +155,9 @@ def test_load_runtime(mocker: MockerFixture, tmp_path, runtime_type, expected_op def test_load_unavailable_runtime(mocker: MockerFixture): - mocker.patch("focoos.runtime.ORT_AVAILABLE", False) - mocker.patch("focoos.runtime.TORCH_AVAILABLE", False) + mocker.patch("focoos.infer.runtimes.load_runtime.ORT_AVAILABLE", False) + mocker.patch("focoos.infer.runtimes.load_runtime.TORCH_AVAILABLE", False) with pytest.raises(ImportError): - load_runtime(RuntimeTypes.TORCHSCRIPT_32, "fake_model_path", MagicMock(spec=ModelMetadata), 2) + load_runtime(RuntimeType.TORCHSCRIPT_32, "fake_model_path", MagicMock(spec=ModelInfo), 2) with pytest.raises(ImportError): - load_runtime(RuntimeTypes.ONNX_CUDA32, "fake_model_path", MagicMock(spec=ModelMetadata), 2) + load_runtime(RuntimeType.ONNX_CUDA32, "fake_model_path", MagicMock(spec=ModelInfo), 2) diff --git a/tests/test_system.py b/tests/test_system.py index b5bde998..f05bc6ef 100644 --- a/tests/test_system.py +++ b/tests/test_system.py @@ -3,7 +3,6 @@ import pytest from focoos.ports import GPUDevice, GPUInfo, SystemInfo -from focoos.utils.api_client import ApiClient from focoos.utils.system import ( get_cpu_name, get_cuda_version, @@ -65,52 +64,3 @@ def test_get_system_info(): assert system_info.cpu_cores > 0 assert system_info.gpu_info is not None assert system_info.gpu_info.gpu_count == 0 - - -def test_api_client_get_external_url(): - client = ApiClient(api_key="test_key", host_url="http://example.com") - with patch("requests.get") as mock_get: - mock_get.return_value.status_code = 200 - response = client.external_get("test/path") - assert response.status_code == 200 - mock_get.assert_called_with("test/path", params={}, stream=False) - - -def test_api_client_get(extra_headers): - client = ApiClient(api_key="test_key", host_url="http://example.com") - with patch("requests.get") as mock_get: - mock_get.return_value.status_code = 200 - response = client.get("test/path", extra_headers=extra_headers) - assert response.status_code == 200 - mock_get.assert_called_with( - "http://example.com/test/path", - headers={**client.default_headers, **extra_headers}, - params=None, - stream=False, - ) - - -def test_api_client_post(extra_headers): - client = ApiClient(api_key="test_key", host_url="http://example.com") - with patch("requests.post") as mock_post: - mock_post.return_value.status_code = 201 - response = client.post("test/path", data={"key": "value"}, extra_headers=extra_headers) - assert response.status_code == 201 - mock_post.assert_called_with( - "http://example.com/test/path", - headers={**client.default_headers, **extra_headers}, - json={"key": "value"}, - files=None, - ) - - -def test_api_client_delete(extra_headers): - client = ApiClient(api_key="test_key", host_url="http://example.com") - with patch("requests.delete") as mock_delete: - mock_delete.return_value.status_code = 204 - response = client.delete("test/path", extra_headers=extra_headers) - assert response.status_code == 204 - mock_delete.assert_called_with( - "http://example.com/test/path", - headers={**client.default_headers, **extra_headers}, - ) diff --git a/tests/utils/test_vision.py b/tests/utils/test_vision.py index b9eab394..3bb1cc8c 100644 --- a/tests/utils/test_vision.py +++ b/tests/utils/test_vision.py @@ -4,7 +4,7 @@ import numpy as np import supervision as sv -from focoos.ports import FocoosDet, FocoosTask +from focoos.ports import FocoosDet, Task from focoos.utils.vision import ( base64mask_to_mask, binary_mask_to_base64, @@ -277,19 +277,19 @@ def test_get_postprocess_fn(): the correct postprocessing function for each task. """ # Test detection task - det_fn = get_postprocess_fn(FocoosTask.DETECTION) + det_fn = get_postprocess_fn(Task.DETECTION) assert det_fn == det_postprocess, "Detection task should return det_postprocess function" # Test instance segmentation task - instance_fn = get_postprocess_fn(FocoosTask.INSTANCE_SEGMENTATION) + instance_fn = get_postprocess_fn(Task.INSTANCE_SEGMENTATION) assert instance_fn == instance_postprocess, "Instance segmentation task should return instance_postprocess function" # Test semantic segmentation task - semseg_fn = get_postprocess_fn(FocoosTask.SEMSEG) + semseg_fn = get_postprocess_fn(Task.SEMSEG) assert semseg_fn == semseg_postprocess, "Semantic segmentation task should return semseg_postprocess function" # Test all FocoosTask values to ensure no exceptions - for task in FocoosTask: + for task in Task: fn = get_postprocess_fn(task) assert callable(fn), f"Postprocess function for {task} should be callable" diff --git a/tutorials/convert_dataset_ninja.ipynb b/tutorials/convert_dataset_ninja.ipynb new file mode 100644 index 00000000..cb80a691 --- /dev/null +++ b/tutorials/convert_dataset_ninja.ipynb @@ -0,0 +1,250 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# ๐Ÿฅท๐Ÿผ Using Dataset Ninja Datasets\n", + "This is a tutorial about converting a dataset in the format we accept in the platform." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Semantic Segmentation\n", + "\n", + "As an example, we will use the [PASCAL VOC 2012](https://host.robots.ox.ac.uk/pascal/VOC/) dataset for semantic segmentation that you can download at [Dataset Ninja platform](https://datasetninja.com/pascal-voc-2012#download)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "dataset_dir = \"../datasets\"\n", + "dataset_name = \"pascal\"\n", + "new_name = \"pascal_mask\"\n", + "\n", + "use_background = True\n", + "ignore_classes = [\"neutral\"]\n", + "ignore_folders = []\n", + "train_split_name = \"train\"\n", + "val_split_name = \"val\"\n", + "image_folder = \"img\"\n", + "mask_folder = \"ann\"" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from focoos.data.converters import convert_datasetninja_to_mask_dataset\n", + "\n", + "convert_datasetninja_to_mask_dataset(\n", + " dataset_root=dataset_dir,\n", + " dataset_name=dataset_name,\n", + " new_name=new_name,\n", + " image_folder=image_folder,\n", + " mask_folder=mask_folder,\n", + " ignore_folders=ignore_folders,\n", + " use_background=use_background,\n", + " ignore_classes=ignore_classes,\n", + " train_split_name=train_split_name,\n", + " val_split_name=val_split_name,\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Let's see if everything is ok! \n", + "We can try to load it with `AutoDataset` and then see some previews." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from focoos.data.auto_dataset import AutoDataset\n", + "from focoos.data.default_aug import DatasetAugmentations\n", + "from focoos.ports import DatasetLayout, DatasetSplitType, Task\n", + "\n", + "task = Task.SEMSEG\n", + "layout = DatasetLayout.ROBOFLOW_SEG\n", + "auto_dataset = AutoDataset(dataset_name=new_name, task=task, layout=layout, datasets_dir=dataset_dir)\n", + "\n", + "augs = DatasetAugmentations(resolution=512)\n", + "\n", + "train_dataset = auto_dataset.get_split(augs=augs.get_augmentations(), split=DatasetSplitType.TRAIN)\n", + "valid_dataset = auto_dataset.get_split(augs=augs.get_augmentations(), split=DatasetSplitType.VAL)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "train_dataset.preview()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Then, if you want to upload the dataset on the Focoos platform, go on the folder of the dataset and zip it." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "%cd {dataset_dir}/{new_name}\n", + "!zip -r {new_name}.zip .\n", + "!mv {new_name}.zip ../\n", + "%cd .." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Object Detection\n", + "\n", + "As an example, we will use a vehicle dataset for object detection that you can download at [Dataset Ninja platform](https://datasetninja.com/vehicle-dataset-for-yolo#download)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "dataset_dir = \"../datasets\"\n", + "dataset_name = \"vehicle\"\n", + "new_name = \"vehicle_coco\"\n", + "\n", + "ignore_classes = []\n", + "ignore_folders = []\n", + "train_split_name = \"train\"\n", + "val_split_name = \"valid\"\n", + "image_folder = \"img\"\n", + "mask_folder = \"ann\"\n", + "remove_json = False" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Let's convert it" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from focoos.data.converters import convert_supervisely_dataset_to_coco\n", + "\n", + "convert_supervisely_dataset_to_coco(\n", + " dataset_dir,\n", + " dataset_name=dataset_name,\n", + " new_name=new_name,\n", + " image_folder=image_folder,\n", + " mask_folder=mask_folder,\n", + " ignore_classes=ignore_classes,\n", + " train_split_name=train_split_name,\n", + " val_split_name=val_split_name,\n", + " remove_json=remove_json,\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Let's load the dataset and check that everything is fine!" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from focoos.data.auto_dataset import AutoDataset\n", + "from focoos.data.default_aug import DatasetAugmentations\n", + "from focoos.ports import DatasetLayout, DatasetSplitType, Task\n", + "\n", + "task = Task.DETECTION\n", + "layout = DatasetLayout.ROBOFLOW_COCO\n", + "auto_dataset = AutoDataset(dataset_name=new_name, task=task, layout=layout, datasets_dir=dataset_dir)\n", + "\n", + "augs = DatasetAugmentations(resolution=512)\n", + "\n", + "train_dataset = auto_dataset.get_split(augs=augs.get_augmentations(), split=DatasetSplitType.TRAIN)\n", + "valid_dataset = auto_dataset.get_split(augs=augs.get_augmentations(), split=DatasetSplitType.VAL)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "valid_dataset.preview()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Then, if you want to upload the dataset on the Focoos platform, go on the folder of the dataset and zip it." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "%cd {dataset_dir}/{new_name}\n", + "!zip -r {new_name}.zip .\n", + "!mv {new_name}.zip ../\n", + "%cd .." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.10" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/tutorials/hub.ipynb b/tutorials/hub.ipynb new file mode 100644 index 00000000..ff3dcc1b --- /dev/null +++ b/tutorials/hub.ipynb @@ -0,0 +1,249 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# ๐Ÿš€ Focoos HUB Integration\n", + "\n", + "This notebook demonstrates how to use FocoosHUB to interact with the Focoos AI platform.\n", + "FocoosHUB provides a seamless integration between your local environment and Focoos cloud services,\n", + "allowing you to:\n", + "- Access and manage your user account and API credentials\n", + "- List, download and deploy remote models from the Focoos model registry\n", + "- Upload and manage your custom trained models\n", + "- Run cloud inference on managed models\n", + "- Monitor model performance and usage metrics" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## ๐Ÿ Setup Focoos" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "%pip install 'focoos @ git+https://github.com/FocoosAI/focoos.git'" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import os\n", + "\n", + "API_KEY = os.getenv(\n", + " \"FOCOOS_API_KEY\"\n", + ") # write here your API key os set env variable FOCOOS_API_KEY, will be load from env if not provided" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## User info" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from focoos.hub import FocoosHUB\n", + "\n", + "hub = FocoosHUB(api_key=API_KEY)\n", + "user_info = hub.get_user_info()\n", + "user_info" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Remote Models" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from focoos.hub import FocoosHUB\n", + "\n", + "hub = FocoosHUB()\n", + "models = hub.list_remote_models()\n", + "models" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Remote Datasets" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "###" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from focoos.hub import FocoosHUB\n", + "\n", + "hub = FocoosHUB()\n", + "datasets = hub.list_remote_datasets(include_shared=True)\n", + "datasets" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Get Model Info" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from focoos.hub import FocoosHUB\n", + "\n", + "model_ref = None # place here the ref of the model you want to retrieve\n", + "\n", + "hub = FocoosHUB()\n", + "if model_ref is not None:\n", + " model_info = hub.get_model_info(model_ref)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Remote Inference with managed models" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from io import BytesIO\n", + "\n", + "import requests\n", + "from PIL import Image\n", + "\n", + "from focoos.hub import FocoosHUB\n", + "from focoos.utils.vision import annotate_image\n", + "\n", + "model_ref = None # place here the ref of the model you want to retrieve\n", + "\n", + "response = requests.get(\"https://public.focoos.ai/samples/pexels-abby-chung.jpg\")\n", + "image = Image.open(BytesIO(response.content))\n", + "\n", + "hub = FocoosHUB()\n", + "if model_ref is not None:\n", + " model = hub.get_remote_model(model_ref)\n", + " results = model.infer(image=image, threshold=0.5)\n", + " annotated_image = annotate_image(\n", + " im=image, detections=results, task=model.model_info.task, classes=model.model_info.classes\n", + " )\n", + "\n", + " display(image)\n", + " display(annotated_image)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Local Training, retrieve dataset from HUB and push Model" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from focoos.data.auto_dataset import AutoDataset\n", + "from focoos.data.default_aug import get_default_by_task\n", + "from focoos.hub.focoos_hub import FocoosHUB\n", + "from focoos.model_manager import ModelManager\n", + "from focoos.ports import DatasetSplitType, TrainerArgs\n", + "\n", + "remote_dataset_ref = None # place here the ref of the dataset you want to download\n", + "\n", + "hub = FocoosHUB()\n", + "if remote_dataset_ref is not None:\n", + " my_datasets = hub.list_remote_datasets(include_shared=False)\n", + " remote_dataset = hub.get_remote_dataset(remote_dataset_ref)\n", + " dataset_path = remote_dataset.download_data()\n", + " auto_dataset = AutoDataset(dataset_name=dataset_path, task=remote_dataset.task, layout=remote_dataset.layout)\n", + "\n", + " train_augs, val_augs = get_default_by_task(remote_dataset.task, 640, advanced=False)\n", + " train_dataset = auto_dataset.get_split(augs=train_augs.get_augmentations(), split=DatasetSplitType.TRAIN)\n", + " valid_dataset = auto_dataset.get_split(augs=val_augs.get_augmentations(), split=DatasetSplitType.VAL)\n", + "\n", + " model = ModelManager.get(\"fai-detr-l-obj365\")\n", + "\n", + " args = TrainerArgs(\n", + " run_name=f\"{remote_dataset.name}-{model.model_info.name}\",\n", + " output_dir=\"./experiments\",\n", + " amp_enabled=True,\n", + " batch_size=16,\n", + " max_iters=500,\n", + " eval_period=50,\n", + " learning_rate=0.0008,\n", + " scheduler=\"MULTISTEP\",\n", + " weight_decay=0.02,\n", + " workers=16,\n", + " patience=1,\n", + " sync_to_hub=True, # use this to sync model info, weights and metrics on the hub\n", + " )\n", + "\n", + " model.train(args, train_dataset, valid_dataset, hub=hub)" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": ".venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.0" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/tutorials/inference.ipynb b/tutorials/inference.ipynb new file mode 100644 index 00000000..7718ec59 --- /dev/null +++ b/tutorials/inference.ipynb @@ -0,0 +1,398 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# ๐Ÿ”ฅ How to use a Computer Vision Model with Focoos" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "๐Ÿ Setup Focoos" + ] + }, + { + "cell_type": "raw", + "metadata": { + "vscode": { + "languageId": "raw" + } + }, + "source": [ + "%pip install 'focoos @ git+https://github.com/FocoosAI/focoos.git'" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# ๐ŸŽจ There are three ways to use a model:\n", + "\n", + "1. Use it on the Focoos' efficient servers with the RemoteModel\n", + "2. Use the model in PyTorch\n", + "3. Use the exported optimized version of the model using a supported inference runtime." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## ๐Ÿ Connect with Focoos" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from focoos.hub import FocoosHUB\n", + "\n", + "FOCOOS_API_KEY = None # write here your API key\n", + "hub = FocoosHUB(api_key=FOCOOS_API_KEY)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Let's also download a sample image to test the model." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from io import BytesIO\n", + "\n", + "import requests\n", + "from PIL import Image\n", + "\n", + "# Download the image\n", + "url = \"https://public.focoos.ai/samples/pexels-abby-chung.jpg\"\n", + "response = requests.get(url)\n", + "image = Image.open(BytesIO(response.content))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## ๐Ÿ“ฆ See your models\n", + "You can see the models available for you on the platform with an intuitive user interface.\n", + "However, you can also list them using the Hub functionalities." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from pprint import pprint\n", + "\n", + "pprint(hub.list_remote_models())" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## ๐ŸŒ Remote Inference\n", + "\n", + "In this section, you'll run a model on the Focoos' servers instead of on your machine. The image will be packed and sent on the network to the servers, where it is processed and the results is retured to your machine, all in few milliseconds. " + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "model_ref = \"fai-detr-l-obj365\" # use any of your models here\n", + "\n", + "model = hub.get_remote_model(model_ref)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Using the model is as simple as it could! Just call it with an image." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "detections = model(image)\n", + "pprint(detections)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "If you want to visualize the result on the image, there's a utily for you." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from focoos.utils.vision import annotate_image\n", + "\n", + "display(annotate_image(image, detections, task=model.model_info.task, classes=model.model_info.classes))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## ๐Ÿ”ฅ Torch Inference\n", + "\n", + "This section demonstrates how to perform local inference using a plain Pytorch model.\n", + "We will load a model and then run inference on a sample image.\n", + "\n", + "First, let's get a model. We need to use the `ModelManager` that will take care of instaciating the right model starting from a pre-trained models, a model ref or a folder " + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from focoos.model_manager import ModelManager\n", + "\n", + "model_ref = \"fai-detr-l-obj365\" # use any of your models here\n", + "\n", + "model = ModelManager.get(model_ref)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "You can now run the model by simply passing it an image" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from pprint import pprint\n", + "\n", + "detections = model(image)\n", + "pprint(detections)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "and visualize the results using the annotate_image utility." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from focoos.utils.vision import annotate_image\n", + "\n", + "display(annotate_image(image, detections, task=model.model_info.task, classes=model.model_info.classes))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "How fast is this model locally? We can compute it's speed by using the benchmark utility." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "model.benchmark(iterations=10, size=640)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## ๐Ÿ”จ Optimized Inference\n", + "\n", + "As you can see, using the torch model is great, but we can achieve better performance by exporting and running it with a optimized runtime, such as Torchscript, TensorRT, CoreML or the ones available on ONNXRuntime.\n", + "\n", + "In the following cells, we will export the previous model for one of these and run it." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Torchscript\n", + "\n", + "We already provide multiple inference runtime, that you can see on the `RuntimeTypes` enum. Let's select Torchscript as an example." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from focoos.ports import RuntimeType\n", + "\n", + "runtime = RuntimeType.TORCHSCRIPT_32" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "It's time to export the model. We can use the export method of the models." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "optimized_model = model.export(runtime_type=runtime, image_size=1024)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Let's visualize the output. As you will see, there are not differences from the model in pure torch." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from focoos.utils.vision import annotate_image\n", + "\n", + "detections = optimized_model(image)\n", + "display(annotate_image(image, detections, task=model.model_info.task, classes=model.model_info.classes))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "But, let's see its latency! " + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "optimized_model.benchmark(iterations=10, size=640)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Wow! That's a lot faster! And without losing a bit in performance!\n", + "\n", + "You can also try different runtimes. Please note that you need to install the relative packages for onnx and tensorRT for using them." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### ONNX with CUDA" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from focoos.ports import RuntimeType\n", + "from focoos.utils.vision import annotate_image\n", + "\n", + "runtime = RuntimeType.ONNX_CUDA32\n", + "optimized_model = model.export(runtime_type=runtime)\n", + "\n", + "detections = optimized_model(image)\n", + "display(annotate_image(image, detections, task=model.model_info.task, classes=model.model_info.classes))\n", + "\n", + "optimized_model.benchmark(iterations=10, size=640)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### ONNX with TensorRT" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from focoos.ports import RuntimeType\n", + "from focoos.utils.vision import annotate_image\n", + "\n", + "runtime = RuntimeType.ONNX_TRT16\n", + "optimized_model = model.export(runtime_type=runtime)\n", + "\n", + "detections = optimized_model(image)\n", + "display(annotate_image(image, detections, task=model.model_info.task, classes=model.model_info.classes))\n", + "\n", + "optimized_model.benchmark(iterations=10, size=640)" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": ".venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.0" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/tutorials/training.ipynb b/tutorials/training.ipynb new file mode 100644 index 00000000..e50c804a --- /dev/null +++ b/tutorials/training.ipynb @@ -0,0 +1,364 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# ๐Ÿง‘๐Ÿฝโ€๐Ÿณ How to Train a Computer Vision Model with Focoos" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## ๐Ÿ Setup Focoos" + ] + }, + { + "cell_type": "raw", + "metadata": { + "vscode": { + "languageId": "raw" + } + }, + "source": [ + "%pip install 'focoos @ git+https://github.com/FocoosAI/focoos.git'" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# ๐ŸŽจ Fine-tune a model in few steps\n", + "\n", + "This section covers the steps to create a model and train it using the focoos library. The following example demonstrates how to interact with the Focoos API to manage models, datasets, and training jobs.\n", + "\n", + "In this guide, we will perform the following steps:\n", + "\n", + "\n", + "0. โ˜๏ธ [Optional] Connect with Focoos Hub\n", + "1. ๐ŸŽฏ Select Pretrained Model\n", + "2. ๐Ÿ“ฆ Load a dataset\n", + "3. ๐Ÿƒโ€โ™‚๏ธ Train the model\n", + "4. ๐Ÿงช Test your model\n", + "5. ๐Ÿ“ค Export your model\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## โ˜๏ธ [Optional] Connect with FocoosHUB\n", + "\n", + "Focoos can be used without having an accont on the [Focoos Hub](app.focoos.ai). With it, you will unlock additional functionalities, as we will see below. If you have it, just connect to the HUB." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import os\n", + "\n", + "from focoos.hub import FocoosHUB\n", + "\n", + "FOCOOS_API_KEY = os.getenv(\n", + " \"FOCOOS_API_KEY\"\n", + ") # write here your API key os set env variable FOCOOS_API_KEY, will be used as default\n", + "hub = FocoosHUB(api_key=FOCOOS_API_KEY)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## ๐ŸŽฏ List Pretrained Focoos Models with ModelRegistry" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from focoos.model_registry import ModelRegistry\n", + "\n", + "model_registry = ModelRegistry()\n", + "\n", + "for m in model_registry.list_models():\n", + " model_info = model_registry.get_model_info(m)\n", + " model_info.pprint()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Load Pretrained Model with ModelManager" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from focoos.model_manager import ModelManager\n", + "\n", + "model_name = \"fai-detr-l-obj365\"\n", + "model = ModelManager.get(model_name)\n", + "model.model_info.pprint()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## ๐Ÿ“ฆ Download datasets\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Public toy datasets" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from focoos.hub.api_client import ApiClient\n", + "from focoos.ports import DATASETS_DIR, DatasetLayout, Task\n", + "\n", + "ds_task = Task.DETECTION\n", + "\n", + "\n", + "def get_dataset(task: Task):\n", + " if task == Task.SEMSEG:\n", + " ds_name = \"balloons-coco-sem.zip\"\n", + " layout = DatasetLayout.ROBOFLOW_SEG\n", + " elif task == Task.DETECTION:\n", + " ds_name = \"chess-coco-detection.zip\"\n", + " layout = DatasetLayout.ROBOFLOW_COCO\n", + " elif task == Task.INSTANCE_SEGMENTATION:\n", + " ds_name = \"fire-coco-instseg.zip\"\n", + " layout = DatasetLayout.ROBOFLOW_COCO\n", + " else:\n", + " raise ValueError(f\"Error: task {task} not supported\")\n", + " url = f\"https://public.focoos.ai/datasets/{ds_name}\"\n", + " api_client = ApiClient()\n", + " api_client.download_ext_file(url, DATASETS_DIR, skip_if_exists=True)\n", + " return ds_name, layout\n", + "\n", + "\n", + "# Downlaod sample dataset\n", + "ds_name, ds_layout = get_dataset(ds_task)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### [Optional] Datasets from focoos Hub" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "If you want to download a dataset from the hub, you can use the hub to directly store it in your local environment.\n", + "Check the reference of your dataset on the platform and use it in the following cell.\n", + "In the next cell, we will download a dataset by reference" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "hub_datasets = hub.list_remote_datasets()\n", + "for dataset in hub_datasets:\n", + " print(dataset.name, dataset.ref)\n", + "\n", + "\n", + "ref = None # place here the ref of the dataset you want to download\n", + "if ref is not None:\n", + " dataset = hub.get_remote_dataset(ref)\n", + " dataset_path = dataset.download_data()\n", + " ds_name = dataset_path\n", + " ds_layout = dataset.layout\n", + " ds_task = dataset.task" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## AutoDataset and Augmentation\n", + "Now that we downloaded the dataset, we can magically ๐Ÿช„ instanciate the dataset using the `AutoDataset` as will be used in the training. You can optionally specify aumgentations for the training using the `DatasetAugmentation` dataclass." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from focoos.data.auto_dataset import AutoDataset\n", + "from focoos.data.default_aug import DatasetAugmentations\n", + "from focoos.ports import DatasetSplitType\n", + "\n", + "auto_dataset = AutoDataset(dataset_name=ds_name, task=ds_task, layout=ds_layout)\n", + "\n", + "train_augs = DatasetAugmentations(\n", + " resolution=512,\n", + " color_augmentation=1.0,\n", + " horizontal_flip=0.5,\n", + " vertical_flip=0.0,\n", + " rotation=0.0,\n", + " aspect_ratio=0.0,\n", + " scale_ratio=0.0,\n", + " crop=True,\n", + ")\n", + "valid_augs = DatasetAugmentations(resolution=512)\n", + "# Optionally, you can also get the default augmentations for the task\n", + "# train_augs, valid_augs = get_default_by_task(task, 512)\n", + "\n", + "train_dataset = auto_dataset.get_split(augs=train_augs.get_augmentations(), split=DatasetSplitType.TRAIN)\n", + "valid_dataset = auto_dataset.get_split(augs=valid_augs.get_augmentations(), split=DatasetSplitType.VAL)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Visualize\n", + "Let's also visualize a few augmented inputs!" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "display(train_dataset.preview())" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## ๐Ÿƒโ€โ™‚๏ธ Train the model\n", + "The next step is to train the model. You can train the model by calling the train method. You need to give it the hyperparameters, encapsulated in the `TrainerArgs`, the datasets and see the magic happens." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from focoos.ports import TrainerArgs\n", + "\n", + "args = TrainerArgs(\n", + " run_name=f\"{model_name}_{auto_dataset.dataset_name}\", # the name of the experiment\n", + " output_dir=\"./experiments\", # the folder where the model is saved, DEFAULT ~/FocoosAI/models\"\n", + " batch_size=16, # how many images in each iteration\n", + " max_iters=500, # how many iterations lasts the training\n", + " eval_period=100, # period after we eval the model on the validation (in iterations)\n", + " learning_rate=0.0001, # learning rate\n", + " weight_decay=0.0001, # regularization strenght (set it properly to avoid under/over fitting)\n", + " sync_to_hub=False,\n", + ") # Use this to sync model info, weights and metrics on the platform\n", + "\n", + "# Let's go!\n", + "model.train(args, train_dataset, valid_dataset)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## ๐Ÿงช Test your model\n", + "Let's visualize some prediction!" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import random\n", + "\n", + "from PIL import Image\n", + "\n", + "from focoos.utils.vision import annotate_image\n", + "\n", + "index = random.randint(0, len(valid_dataset))\n", + "\n", + "print(\"Ground truth:\")\n", + "display(valid_dataset.preview(index, use_augmentations=False))\n", + "\n", + "image = Image.open(valid_dataset[index][\"file_name\"])\n", + "outputs = model(image)\n", + "\n", + "print(\"Prediction:\")\n", + "annotate_image(image, outputs, task=model.task, classes=model.model_info.classes)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## ๐Ÿ“ค Export Model and optimize inference " + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from focoos.ports import RuntimeType\n", + "\n", + "infer_model = model.export(runtime_type=RuntimeType.TORCHSCRIPT_32)\n", + "\n", + "infer_model.benchmark(iterations=10)\n", + "detections = infer_model.infer(image, threshold=0.5)" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": ".venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.0" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/uv.lock b/uv.lock new file mode 100644 index 00000000..9ce28c9b --- /dev/null +++ b/uv.lock @@ -0,0 +1,3543 @@ +version = 1 +revision = 2 +requires-python = ">=3.10" +resolution-markers = [ + "python_full_version < '3.11' and sys_platform == 'darwin'", + "python_version < '0'", + "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "(python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux')", + "python_full_version == '3.11.*' and sys_platform == 'darwin'", + "python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "(python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform != 'darwin' and sys_platform != 'linux')", + "python_full_version == '3.12.*' and sys_platform == 'darwin'", + "python_full_version == '3.12.*' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "(python_full_version == '3.12.*' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.12.*' and sys_platform != 'darwin' and sys_platform != 'linux')", + "python_full_version >= '3.13' and sys_platform == 'darwin'", + "python_full_version >= '3.13' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "(python_full_version >= '3.13' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.13' and sys_platform != 'darwin' and sys_platform != 'linux')", +] + +[[package]] +name = "absl-py" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/03/15/18693af986560a5c3cc0b84a8046b536ffb2cdb536e03cce897f2759e284/absl_py-2.3.0.tar.gz", hash = "sha256:d96fda5c884f1b22178852f30ffa85766d50b99e00775ea626c23304f582fc4f", size = 116400, upload-time = "2025-05-27T09:15:50.143Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/04/9d75e1d3bb4ab8ec67ff10919476ccdee06c098bcfcf3a352da5f985171d/absl_py-2.3.0-py3-none-any.whl", hash = "sha256:9824a48b654a306168f63e0d97714665f8490b8d89ec7bf2efc24bf67cf579b3", size = 135657, upload-time = "2025-05-27T09:15:48.742Z" }, +] + +[[package]] +name = "aiofiles" +version = "24.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/03/a88171e277e8caa88a4c77808c20ebb04ba74cc4681bf1e9416c862de237/aiofiles-24.1.0.tar.gz", hash = "sha256:22a075c9e5a3810f0c2e48f3008c94d68c65d763b9b03857924c99e57355166c", size = 30247, upload-time = "2024-06-24T11:02:03.584Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/45/30bb92d442636f570cb5651bc661f52b610e2eec3f891a5dc3a4c3667db0/aiofiles-24.1.0-py3-none-any.whl", hash = "sha256:b4ec55f4195e3eb5d7abd1bf7e061763e864dd4954231fb8539a0ef8bb8260e5", size = 15896, upload-time = "2024-06-24T11:02:01.529Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "sniffio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/95/7d/4c1bd541d4dffa1b52bd83fb8527089e097a106fc90b467a7313b105f840/anyio-4.9.0.tar.gz", hash = "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028", size = 190949, upload-time = "2025-03-17T00:02:54.77Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a1/ee/48ca1a7c89ffec8b6a0c5d02b89c305671d5ffd8d3c94acf8b8c408575bb/anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c", size = 100916, upload-time = "2025-03-17T00:02:52.713Z" }, +] + +[[package]] +name = "appnope" +version = "0.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/35/5d/752690df9ef5b76e169e68d6a129fa6d08a7100ca7f754c89495db3c6019/appnope-0.1.4.tar.gz", hash = "sha256:1de3860566df9caf38f01f86f65e0e13e379af54f9e4bee1e66b48f2efffd1ee", size = 4170, upload-time = "2024-02-06T09:43:11.258Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/29/5ecc3a15d5a33e31b26c11426c45c501e439cb865d0bff96315d86443b78/appnope-0.1.4-py2.py3-none-any.whl", hash = "sha256:502575ee11cd7a28c0205f379b525beefebab9d161b7c964670864014ed7213c", size = 4321, upload-time = "2024-02-06T09:43:09.663Z" }, +] + +[[package]] +name = "asttokens" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4a/e7/82da0a03e7ba5141f05cce0d302e6eed121ae055e0456ca228bf693984bc/asttokens-3.0.0.tar.gz", hash = "sha256:0dcd8baa8d62b0c1d118b399b2ddba3c4aff271d0d7a9e0d4c1681c79035bbc7", size = 61978, upload-time = "2024-11-30T04:30:14.439Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/8a/c46dcc25341b5bce5472c718902eb3d38600a903b14fa6aeecef3f21a46f/asttokens-3.0.0-py3-none-any.whl", hash = "sha256:e3078351a059199dd5138cb1c706e6430c05eff2ff136af5eb4790f9d28932e2", size = 26918, upload-time = "2024-11-30T04:30:10.946Z" }, +] + +[[package]] +name = "audioop-lts" +version = "0.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dd/3b/69ff8a885e4c1c42014c2765275c4bd91fe7bc9847e9d8543dbcbb09f820/audioop_lts-0.2.1.tar.gz", hash = "sha256:e81268da0baa880431b68b1308ab7257eb33f356e57a5f9b1f915dfb13dd1387", size = 30204, upload-time = "2024-08-04T21:14:43.957Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/01/91/a219253cc6e92db2ebeaf5cf8197f71d995df6f6b16091d1f3ce62cb169d/audioop_lts-0.2.1-cp313-abi3-macosx_10_13_universal2.whl", hash = "sha256:fd1345ae99e17e6910f47ce7d52673c6a1a70820d78b67de1b7abb3af29c426a", size = 46252, upload-time = "2024-08-04T21:13:56.209Z" }, + { url = "https://files.pythonhosted.org/packages/ec/f6/3cb21e0accd9e112d27cee3b1477cd04dafe88675c54ad8b0d56226c1e0b/audioop_lts-0.2.1-cp313-abi3-macosx_10_13_x86_64.whl", hash = "sha256:e175350da05d2087e12cea8e72a70a1a8b14a17e92ed2022952a4419689ede5e", size = 27183, upload-time = "2024-08-04T21:13:59.966Z" }, + { url = "https://files.pythonhosted.org/packages/ea/7e/f94c8a6a8b2571694375b4cf94d3e5e0f529e8e6ba280fad4d8c70621f27/audioop_lts-0.2.1-cp313-abi3-macosx_11_0_arm64.whl", hash = "sha256:4a8dd6a81770f6ecf019c4b6d659e000dc26571b273953cef7cd1d5ce2ff3ae6", size = 26726, upload-time = "2024-08-04T21:14:00.846Z" }, + { url = "https://files.pythonhosted.org/packages/ef/f8/a0e8e7a033b03fae2b16bc5aa48100b461c4f3a8a38af56d5ad579924a3a/audioop_lts-0.2.1-cp313-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d1cd3c0b6f2ca25c7d2b1c3adeecbe23e65689839ba73331ebc7d893fcda7ffe", size = 80718, upload-time = "2024-08-04T21:14:01.989Z" }, + { url = "https://files.pythonhosted.org/packages/8f/ea/a98ebd4ed631c93b8b8f2368862cd8084d75c77a697248c24437c36a6f7e/audioop_lts-0.2.1-cp313-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ff3f97b3372c97782e9c6d3d7fdbe83bce8f70de719605bd7ee1839cd1ab360a", size = 88326, upload-time = "2024-08-04T21:14:03.509Z" }, + { url = "https://files.pythonhosted.org/packages/33/79/e97a9f9daac0982aa92db1199339bd393594d9a4196ad95ae088635a105f/audioop_lts-0.2.1-cp313-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a351af79edefc2a1bd2234bfd8b339935f389209943043913a919df4b0f13300", size = 80539, upload-time = "2024-08-04T21:14:04.679Z" }, + { url = "https://files.pythonhosted.org/packages/b2/d3/1051d80e6f2d6f4773f90c07e73743a1e19fcd31af58ff4e8ef0375d3a80/audioop_lts-0.2.1-cp313-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2aeb6f96f7f6da80354330470b9134d81b4cf544cdd1c549f2f45fe964d28059", size = 78577, upload-time = "2024-08-04T21:14:09.038Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1d/54f4c58bae8dc8c64a75071c7e98e105ddaca35449376fcb0180f6e3c9df/audioop_lts-0.2.1-cp313-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c589f06407e8340e81962575fcffbba1e92671879a221186c3d4662de9fe804e", size = 82074, upload-time = "2024-08-04T21:14:09.99Z" }, + { url = "https://files.pythonhosted.org/packages/36/89/2e78daa7cebbea57e72c0e1927413be4db675548a537cfba6a19040d52fa/audioop_lts-0.2.1-cp313-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fbae5d6925d7c26e712f0beda5ed69ebb40e14212c185d129b8dfbfcc335eb48", size = 84210, upload-time = "2024-08-04T21:14:11.468Z" }, + { url = "https://files.pythonhosted.org/packages/a5/57/3ff8a74df2ec2fa6d2ae06ac86e4a27d6412dbb7d0e0d41024222744c7e0/audioop_lts-0.2.1-cp313-abi3-musllinux_1_2_i686.whl", hash = "sha256:d2d5434717f33117f29b5691fbdf142d36573d751716249a288fbb96ba26a281", size = 85664, upload-time = "2024-08-04T21:14:12.394Z" }, + { url = "https://files.pythonhosted.org/packages/16/01/21cc4e5878f6edbc8e54be4c108d7cb9cb6202313cfe98e4ece6064580dd/audioop_lts-0.2.1-cp313-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:f626a01c0a186b08f7ff61431c01c055961ee28769591efa8800beadd27a2959", size = 93255, upload-time = "2024-08-04T21:14:13.707Z" }, + { url = "https://files.pythonhosted.org/packages/3e/28/7f7418c362a899ac3b0bf13b1fde2d4ffccfdeb6a859abd26f2d142a1d58/audioop_lts-0.2.1-cp313-abi3-musllinux_1_2_s390x.whl", hash = "sha256:05da64e73837f88ee5c6217d732d2584cf638003ac72df124740460531e95e47", size = 87760, upload-time = "2024-08-04T21:14:14.74Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d8/577a8be87dc7dd2ba568895045cee7d32e81d85a7e44a29000fe02c4d9d4/audioop_lts-0.2.1-cp313-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:56b7a0a4dba8e353436f31a932f3045d108a67b5943b30f85a5563f4d8488d77", size = 84992, upload-time = "2024-08-04T21:14:19.155Z" }, + { url = "https://files.pythonhosted.org/packages/ef/9a/4699b0c4fcf89936d2bfb5425f55f1a8b86dff4237cfcc104946c9cd9858/audioop_lts-0.2.1-cp313-abi3-win32.whl", hash = "sha256:6e899eb8874dc2413b11926b5fb3857ec0ab55222840e38016a6ba2ea9b7d5e3", size = 26059, upload-time = "2024-08-04T21:14:20.438Z" }, + { url = "https://files.pythonhosted.org/packages/3a/1c/1f88e9c5dd4785a547ce5fd1eb83fff832c00cc0e15c04c1119b02582d06/audioop_lts-0.2.1-cp313-abi3-win_amd64.whl", hash = "sha256:64562c5c771fb0a8b6262829b9b4f37a7b886c01b4d3ecdbae1d629717db08b4", size = 30412, upload-time = "2024-08-04T21:14:21.342Z" }, + { url = "https://files.pythonhosted.org/packages/c4/e9/c123fd29d89a6402ad261516f848437472ccc602abb59bba522af45e281b/audioop_lts-0.2.1-cp313-abi3-win_arm64.whl", hash = "sha256:c45317debeb64002e980077642afbd977773a25fa3dfd7ed0c84dccfc1fafcb0", size = 23578, upload-time = "2024-08-04T21:14:22.193Z" }, + { url = "https://files.pythonhosted.org/packages/7a/99/bb664a99561fd4266687e5cb8965e6ec31ba4ff7002c3fce3dc5ef2709db/audioop_lts-0.2.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:3827e3fce6fee4d69d96a3d00cd2ab07f3c0d844cb1e44e26f719b34a5b15455", size = 46827, upload-time = "2024-08-04T21:14:23.034Z" }, + { url = "https://files.pythonhosted.org/packages/c4/e3/f664171e867e0768ab982715e744430cf323f1282eb2e11ebfb6ee4c4551/audioop_lts-0.2.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:161249db9343b3c9780ca92c0be0d1ccbfecdbccac6844f3d0d44b9c4a00a17f", size = 27479, upload-time = "2024-08-04T21:14:23.922Z" }, + { url = "https://files.pythonhosted.org/packages/a6/0d/2a79231ff54eb20e83b47e7610462ad6a2bea4e113fae5aa91c6547e7764/audioop_lts-0.2.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5b7b4ff9de7a44e0ad2618afdc2ac920b91f4a6d3509520ee65339d4acde5abf", size = 27056, upload-time = "2024-08-04T21:14:28.061Z" }, + { url = "https://files.pythonhosted.org/packages/86/46/342471398283bb0634f5a6df947806a423ba74b2e29e250c7ec0e3720e4f/audioop_lts-0.2.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:72e37f416adb43b0ced93419de0122b42753ee74e87070777b53c5d2241e7fab", size = 87802, upload-time = "2024-08-04T21:14:29.586Z" }, + { url = "https://files.pythonhosted.org/packages/56/44/7a85b08d4ed55517634ff19ddfbd0af05bf8bfd39a204e4445cd0e6f0cc9/audioop_lts-0.2.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:534ce808e6bab6adb65548723c8cbe189a3379245db89b9d555c4210b4aaa9b6", size = 95016, upload-time = "2024-08-04T21:14:30.481Z" }, + { url = "https://files.pythonhosted.org/packages/a8/2a/45edbca97ea9ee9e6bbbdb8d25613a36e16a4d1e14ae01557392f15cc8d3/audioop_lts-0.2.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d2de9b6fb8b1cf9f03990b299a9112bfdf8b86b6987003ca9e8a6c4f56d39543", size = 87394, upload-time = "2024-08-04T21:14:31.883Z" }, + { url = "https://files.pythonhosted.org/packages/14/ae/832bcbbef2c510629593bf46739374174606e25ac7d106b08d396b74c964/audioop_lts-0.2.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f24865991b5ed4b038add5edbf424639d1358144f4e2a3e7a84bc6ba23e35074", size = 84874, upload-time = "2024-08-04T21:14:32.751Z" }, + { url = "https://files.pythonhosted.org/packages/26/1c/8023c3490798ed2f90dfe58ec3b26d7520a243ae9c0fc751ed3c9d8dbb69/audioop_lts-0.2.1-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bdb3b7912ccd57ea53197943f1bbc67262dcf29802c4a6df79ec1c715d45a78", size = 88698, upload-time = "2024-08-04T21:14:34.147Z" }, + { url = "https://files.pythonhosted.org/packages/2c/db/5379d953d4918278b1f04a5a64b2c112bd7aae8f81021009da0dcb77173c/audioop_lts-0.2.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:120678b208cca1158f0a12d667af592e067f7a50df9adc4dc8f6ad8d065a93fb", size = 90401, upload-time = "2024-08-04T21:14:35.276Z" }, + { url = "https://files.pythonhosted.org/packages/99/6e/3c45d316705ab1aec2e69543a5b5e458d0d112a93d08994347fafef03d50/audioop_lts-0.2.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:54cd4520fc830b23c7d223693ed3e1b4d464997dd3abc7c15dce9a1f9bd76ab2", size = 91864, upload-time = "2024-08-04T21:14:36.158Z" }, + { url = "https://files.pythonhosted.org/packages/08/58/6a371d8fed4f34debdb532c0b00942a84ebf3e7ad368e5edc26931d0e251/audioop_lts-0.2.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:d6bd20c7a10abcb0fb3d8aaa7508c0bf3d40dfad7515c572014da4b979d3310a", size = 98796, upload-time = "2024-08-04T21:14:37.185Z" }, + { url = "https://files.pythonhosted.org/packages/ee/77/d637aa35497e0034ff846fd3330d1db26bc6fd9dd79c406e1341188b06a2/audioop_lts-0.2.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:f0ed1ad9bd862539ea875fb339ecb18fcc4148f8d9908f4502df28f94d23491a", size = 94116, upload-time = "2024-08-04T21:14:38.145Z" }, + { url = "https://files.pythonhosted.org/packages/1a/60/7afc2abf46bbcf525a6ebc0305d85ab08dc2d1e2da72c48dbb35eee5b62c/audioop_lts-0.2.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:e1af3ff32b8c38a7d900382646e91f2fc515fd19dea37e9392275a5cbfdbff63", size = 91520, upload-time = "2024-08-04T21:14:39.128Z" }, + { url = "https://files.pythonhosted.org/packages/65/6d/42d40da100be1afb661fd77c2b1c0dfab08af1540df57533621aea3db52a/audioop_lts-0.2.1-cp313-cp313t-win32.whl", hash = "sha256:f51bb55122a89f7a0817d7ac2319744b4640b5b446c4c3efcea5764ea99ae509", size = 26482, upload-time = "2024-08-04T21:14:40.269Z" }, + { url = "https://files.pythonhosted.org/packages/01/09/f08494dca79f65212f5b273aecc5a2f96691bf3307cac29acfcf84300c01/audioop_lts-0.2.1-cp313-cp313t-win_amd64.whl", hash = "sha256:f0f2f336aa2aee2bce0b0dcc32bbba9178995454c7b979cf6ce086a8801e14c7", size = 30780, upload-time = "2024-08-04T21:14:41.128Z" }, + { url = "https://files.pythonhosted.org/packages/5d/35/be73b6015511aa0173ec595fc579133b797ad532996f2998fd6b8d1bbe6b/audioop_lts-0.2.1-cp313-cp313t-win_arm64.whl", hash = "sha256:78bfb3703388c780edf900be66e07de5a3d4105ca8e8720c5c4d67927e0b15d0", size = 23918, upload-time = "2024-08-04T21:14:42.803Z" }, +] + +[[package]] +name = "babel" +version = "2.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/6b/d52e42361e1aa00709585ecc30b3f9684b3ab62530771402248b1b1d6240/babel-2.17.0.tar.gz", hash = "sha256:0c54cffb19f690cdcc52a3b50bcbf71e07a808d1c80d549f2459b9d2cf0afb9d", size = 9951852, upload-time = "2025-02-01T15:17:41.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b8/3fe70c75fe32afc4bb507f75563d39bc5642255d1d94f1f23604725780bf/babel-2.17.0-py3-none-any.whl", hash = "sha256:4d0b53093fdfb4b21c92b5213dba5a1b23885afa8383709427046b21c366e5f2", size = 10182537, upload-time = "2025-02-01T15:17:37.39Z" }, +] + +[[package]] +name = "backrefs" +version = "5.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6c/46/caba1eb32fa5784428ab401a5487f73db4104590ecd939ed9daaf18b47e0/backrefs-5.8.tar.gz", hash = "sha256:2cab642a205ce966af3dd4b38ee36009b31fa9502a35fd61d59ccc116e40a6bd", size = 6773994, upload-time = "2025-02-25T18:15:32.003Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/cb/d019ab87fe70e0fe3946196d50d6a4428623dc0c38a6669c8cae0320fbf3/backrefs-5.8-py310-none-any.whl", hash = "sha256:c67f6638a34a5b8730812f5101376f9d41dc38c43f1fdc35cb54700f6ed4465d", size = 380337, upload-time = "2025-02-25T16:53:14.607Z" }, + { url = "https://files.pythonhosted.org/packages/a9/86/abd17f50ee21b2248075cb6924c6e7f9d23b4925ca64ec660e869c2633f1/backrefs-5.8-py311-none-any.whl", hash = "sha256:2e1c15e4af0e12e45c8701bd5da0902d326b2e200cafcd25e49d9f06d44bb61b", size = 392142, upload-time = "2025-02-25T16:53:17.266Z" }, + { url = "https://files.pythonhosted.org/packages/b3/04/7b415bd75c8ab3268cc138c76fa648c19495fcc7d155508a0e62f3f82308/backrefs-5.8-py312-none-any.whl", hash = "sha256:bbef7169a33811080d67cdf1538c8289f76f0942ff971222a16034da88a73486", size = 398021, upload-time = "2025-02-25T16:53:26.378Z" }, + { url = "https://files.pythonhosted.org/packages/04/b8/60dcfb90eb03a06e883a92abbc2ab95c71f0d8c9dd0af76ab1d5ce0b1402/backrefs-5.8-py313-none-any.whl", hash = "sha256:e3a63b073867dbefd0536425f43db618578528e3896fb77be7141328642a1585", size = 399915, upload-time = "2025-02-25T16:53:28.167Z" }, + { url = "https://files.pythonhosted.org/packages/0c/37/fb6973edeb700f6e3d6ff222400602ab1830446c25c7b4676d8de93e65b8/backrefs-5.8-py39-none-any.whl", hash = "sha256:a66851e4533fb5b371aa0628e1fee1af05135616b86140c9d787a2ffdf4b8fdc", size = 380336, upload-time = "2025-02-25T16:53:29.858Z" }, +] + +[[package]] +name = "bracex" +version = "2.5.post1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/6c/57418c4404cd22fe6275b8301ca2b46a8cdaa8157938017a9ae0b3edf363/bracex-2.5.post1.tar.gz", hash = "sha256:12c50952415bfa773d2d9ccb8e79651b8cdb1f31a42f6091b804f6ba2b4a66b6", size = 26641, upload-time = "2024-09-28T21:41:22.017Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/02/8db98cdc1a58e0abd6716d5e63244658e6e63513c65f469f34b6f1053fd0/bracex-2.5.post1-py3-none-any.whl", hash = "sha256:13e5732fec27828d6af308628285ad358047cec36801598368cb28bc631dbaf6", size = 11558, upload-time = "2024-09-28T21:41:21.016Z" }, +] + +[[package]] +name = "cachetools" +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c0/b0/f539a1ddff36644c28a61490056e5bae43bd7386d9f9c69beae2d7e7d6d1/cachetools-6.0.0.tar.gz", hash = "sha256:f225782b84438f828328fc2ad74346522f27e5b1440f4e9fd18b20ebfd1aa2cf", size = 30160, upload-time = "2025-05-23T20:01:13.076Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/c3/8bb087c903c95a570015ce84e0c23ae1d79f528c349cbc141b5c4e250293/cachetools-6.0.0-py3-none-any.whl", hash = "sha256:82e73ba88f7b30228b5507dce1a1f878498fc669d972aef2dde4f3a3c24f103e", size = 10964, upload-time = "2025-05-23T20:01:11.323Z" }, +] + +[[package]] +name = "certifi" +version = "2025.4.26" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/9e/c05b3920a3b7d20d3d3310465f50348e5b3694f4f88c6daf736eef3024c4/certifi-2025.4.26.tar.gz", hash = "sha256:0a816057ea3cdefcef70270d2c515e4506bbc954f417fa5ade2021213bb8f0c6", size = 160705, upload-time = "2025-04-26T02:12:29.51Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/7e/3db2bd1b1f9e95f7cddca6d6e75e2f2bd9f51b1246e546d88addca0106bd/certifi-2025.4.26-py3-none-any.whl", hash = "sha256:30350364dfe371162649852c63336a15c70c6510c2ad5015b21c2345311805f3", size = 159618, upload-time = "2025-04-26T02:12:27.662Z" }, +] + +[[package]] +name = "cffi" +version = "1.17.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/97/c783634659c2920c3fc70419e3af40972dbaf758daa229a7d6ea6135c90d/cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824", size = 516621, upload-time = "2024-09-04T20:45:21.852Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/07/f44ca684db4e4f08a3fdc6eeb9a0d15dc6883efc7b8c90357fdbf74e186c/cffi-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14", size = 182191, upload-time = "2024-09-04T20:43:30.027Z" }, + { url = "https://files.pythonhosted.org/packages/08/fd/cc2fedbd887223f9f5d170c96e57cbf655df9831a6546c1727ae13fa977a/cffi-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67", size = 178592, upload-time = "2024-09-04T20:43:32.108Z" }, + { url = "https://files.pythonhosted.org/packages/de/cc/4635c320081c78d6ffc2cab0a76025b691a91204f4aa317d568ff9280a2d/cffi-1.17.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382", size = 426024, upload-time = "2024-09-04T20:43:34.186Z" }, + { url = "https://files.pythonhosted.org/packages/b6/7b/3b2b250f3aab91abe5f8a51ada1b717935fdaec53f790ad4100fe2ec64d1/cffi-1.17.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702", size = 448188, upload-time = "2024-09-04T20:43:36.286Z" }, + { url = "https://files.pythonhosted.org/packages/d3/48/1b9283ebbf0ec065148d8de05d647a986c5f22586b18120020452fff8f5d/cffi-1.17.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3", size = 455571, upload-time = "2024-09-04T20:43:38.586Z" }, + { url = "https://files.pythonhosted.org/packages/40/87/3b8452525437b40f39ca7ff70276679772ee7e8b394934ff60e63b7b090c/cffi-1.17.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5da5719280082ac6bd9aa7becb3938dc9f9cbd57fac7d2871717b1feb0902ab6", size = 436687, upload-time = "2024-09-04T20:43:40.084Z" }, + { url = "https://files.pythonhosted.org/packages/8d/fb/4da72871d177d63649ac449aec2e8a29efe0274035880c7af59101ca2232/cffi-1.17.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17", size = 446211, upload-time = "2024-09-04T20:43:41.526Z" }, + { url = "https://files.pythonhosted.org/packages/ab/a0/62f00bcb411332106c02b663b26f3545a9ef136f80d5df746c05878f8c4b/cffi-1.17.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8", size = 461325, upload-time = "2024-09-04T20:43:43.117Z" }, + { url = "https://files.pythonhosted.org/packages/36/83/76127035ed2e7e27b0787604d99da630ac3123bfb02d8e80c633f218a11d/cffi-1.17.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e", size = 438784, upload-time = "2024-09-04T20:43:45.256Z" }, + { url = "https://files.pythonhosted.org/packages/21/81/a6cd025db2f08ac88b901b745c163d884641909641f9b826e8cb87645942/cffi-1.17.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be", size = 461564, upload-time = "2024-09-04T20:43:46.779Z" }, + { url = "https://files.pythonhosted.org/packages/f8/fe/4d41c2f200c4a457933dbd98d3cf4e911870877bd94d9656cc0fcb390681/cffi-1.17.1-cp310-cp310-win32.whl", hash = "sha256:c9c3d058ebabb74db66e431095118094d06abf53284d9c81f27300d0e0d8bc7c", size = 171804, upload-time = "2024-09-04T20:43:48.186Z" }, + { url = "https://files.pythonhosted.org/packages/d1/b6/0b0f5ab93b0df4acc49cae758c81fe4e5ef26c3ae2e10cc69249dfd8b3ab/cffi-1.17.1-cp310-cp310-win_amd64.whl", hash = "sha256:0f048dcf80db46f0098ccac01132761580d28e28bc0f78ae0d58048063317e15", size = 181299, upload-time = "2024-09-04T20:43:49.812Z" }, + { url = "https://files.pythonhosted.org/packages/6b/f4/927e3a8899e52a27fa57a48607ff7dc91a9ebe97399b357b85a0c7892e00/cffi-1.17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401", size = 182264, upload-time = "2024-09-04T20:43:51.124Z" }, + { url = "https://files.pythonhosted.org/packages/6c/f5/6c3a8efe5f503175aaddcbea6ad0d2c96dad6f5abb205750d1b3df44ef29/cffi-1.17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf", size = 178651, upload-time = "2024-09-04T20:43:52.872Z" }, + { url = "https://files.pythonhosted.org/packages/94/dd/a3f0118e688d1b1a57553da23b16bdade96d2f9bcda4d32e7d2838047ff7/cffi-1.17.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4", size = 445259, upload-time = "2024-09-04T20:43:56.123Z" }, + { url = "https://files.pythonhosted.org/packages/2e/ea/70ce63780f096e16ce8588efe039d3c4f91deb1dc01e9c73a287939c79a6/cffi-1.17.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41", size = 469200, upload-time = "2024-09-04T20:43:57.891Z" }, + { url = "https://files.pythonhosted.org/packages/1c/a0/a4fa9f4f781bda074c3ddd57a572b060fa0df7655d2a4247bbe277200146/cffi-1.17.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1", size = 477235, upload-time = "2024-09-04T20:44:00.18Z" }, + { url = "https://files.pythonhosted.org/packages/62/12/ce8710b5b8affbcdd5c6e367217c242524ad17a02fe5beec3ee339f69f85/cffi-1.17.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6", size = 459721, upload-time = "2024-09-04T20:44:01.585Z" }, + { url = "https://files.pythonhosted.org/packages/ff/6b/d45873c5e0242196f042d555526f92aa9e0c32355a1be1ff8c27f077fd37/cffi-1.17.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d", size = 467242, upload-time = "2024-09-04T20:44:03.467Z" }, + { url = "https://files.pythonhosted.org/packages/1a/52/d9a0e523a572fbccf2955f5abe883cfa8bcc570d7faeee06336fbd50c9fc/cffi-1.17.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6", size = 477999, upload-time = "2024-09-04T20:44:05.023Z" }, + { url = "https://files.pythonhosted.org/packages/44/74/f2a2460684a1a2d00ca799ad880d54652841a780c4c97b87754f660c7603/cffi-1.17.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f", size = 454242, upload-time = "2024-09-04T20:44:06.444Z" }, + { url = "https://files.pythonhosted.org/packages/f8/4a/34599cac7dfcd888ff54e801afe06a19c17787dfd94495ab0c8d35fe99fb/cffi-1.17.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b", size = 478604, upload-time = "2024-09-04T20:44:08.206Z" }, + { url = "https://files.pythonhosted.org/packages/34/33/e1b8a1ba29025adbdcda5fb3a36f94c03d771c1b7b12f726ff7fef2ebe36/cffi-1.17.1-cp311-cp311-win32.whl", hash = "sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655", size = 171727, upload-time = "2024-09-04T20:44:09.481Z" }, + { url = "https://files.pythonhosted.org/packages/3d/97/50228be003bb2802627d28ec0627837ac0bf35c90cf769812056f235b2d1/cffi-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0", size = 181400, upload-time = "2024-09-04T20:44:10.873Z" }, + { url = "https://files.pythonhosted.org/packages/5a/84/e94227139ee5fb4d600a7a4927f322e1d4aea6fdc50bd3fca8493caba23f/cffi-1.17.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4", size = 183178, upload-time = "2024-09-04T20:44:12.232Z" }, + { url = "https://files.pythonhosted.org/packages/da/ee/fb72c2b48656111c4ef27f0f91da355e130a923473bf5ee75c5643d00cca/cffi-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c", size = 178840, upload-time = "2024-09-04T20:44:13.739Z" }, + { url = "https://files.pythonhosted.org/packages/cc/b6/db007700f67d151abadf508cbfd6a1884f57eab90b1bb985c4c8c02b0f28/cffi-1.17.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36", size = 454803, upload-time = "2024-09-04T20:44:15.231Z" }, + { url = "https://files.pythonhosted.org/packages/1a/df/f8d151540d8c200eb1c6fba8cd0dfd40904f1b0682ea705c36e6c2e97ab3/cffi-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5", size = 478850, upload-time = "2024-09-04T20:44:17.188Z" }, + { url = "https://files.pythonhosted.org/packages/28/c0/b31116332a547fd2677ae5b78a2ef662dfc8023d67f41b2a83f7c2aa78b1/cffi-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff", size = 485729, upload-time = "2024-09-04T20:44:18.688Z" }, + { url = "https://files.pythonhosted.org/packages/91/2b/9a1ddfa5c7f13cab007a2c9cc295b70fbbda7cb10a286aa6810338e60ea1/cffi-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99", size = 471256, upload-time = "2024-09-04T20:44:20.248Z" }, + { url = "https://files.pythonhosted.org/packages/b2/d5/da47df7004cb17e4955df6a43d14b3b4ae77737dff8bf7f8f333196717bf/cffi-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93", size = 479424, upload-time = "2024-09-04T20:44:21.673Z" }, + { url = "https://files.pythonhosted.org/packages/0b/ac/2a28bcf513e93a219c8a4e8e125534f4f6db03e3179ba1c45e949b76212c/cffi-1.17.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3", size = 484568, upload-time = "2024-09-04T20:44:23.245Z" }, + { url = "https://files.pythonhosted.org/packages/d4/38/ca8a4f639065f14ae0f1d9751e70447a261f1a30fa7547a828ae08142465/cffi-1.17.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8", size = 488736, upload-time = "2024-09-04T20:44:24.757Z" }, + { url = "https://files.pythonhosted.org/packages/86/c5/28b2d6f799ec0bdecf44dced2ec5ed43e0eb63097b0f58c293583b406582/cffi-1.17.1-cp312-cp312-win32.whl", hash = "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65", size = 172448, upload-time = "2024-09-04T20:44:26.208Z" }, + { url = "https://files.pythonhosted.org/packages/50/b9/db34c4755a7bd1cb2d1603ac3863f22bcecbd1ba29e5ee841a4bc510b294/cffi-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903", size = 181976, upload-time = "2024-09-04T20:44:27.578Z" }, + { url = "https://files.pythonhosted.org/packages/8d/f8/dd6c246b148639254dad4d6803eb6a54e8c85c6e11ec9df2cffa87571dbe/cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e", size = 182989, upload-time = "2024-09-04T20:44:28.956Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f1/672d303ddf17c24fc83afd712316fda78dc6fce1cd53011b839483e1ecc8/cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2", size = 178802, upload-time = "2024-09-04T20:44:30.289Z" }, + { url = "https://files.pythonhosted.org/packages/0e/2d/eab2e858a91fdff70533cab61dcff4a1f55ec60425832ddfdc9cd36bc8af/cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3", size = 454792, upload-time = "2024-09-04T20:44:32.01Z" }, + { url = "https://files.pythonhosted.org/packages/75/b2/fbaec7c4455c604e29388d55599b99ebcc250a60050610fadde58932b7ee/cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683", size = 478893, upload-time = "2024-09-04T20:44:33.606Z" }, + { url = "https://files.pythonhosted.org/packages/4f/b7/6e4a2162178bf1935c336d4da8a9352cccab4d3a5d7914065490f08c0690/cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5", size = 485810, upload-time = "2024-09-04T20:44:35.191Z" }, + { url = "https://files.pythonhosted.org/packages/c7/8a/1d0e4a9c26e54746dc08c2c6c037889124d4f59dffd853a659fa545f1b40/cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4", size = 471200, upload-time = "2024-09-04T20:44:36.743Z" }, + { url = "https://files.pythonhosted.org/packages/26/9f/1aab65a6c0db35f43c4d1b4f580e8df53914310afc10ae0397d29d697af4/cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd", size = 479447, upload-time = "2024-09-04T20:44:38.492Z" }, + { url = "https://files.pythonhosted.org/packages/5f/e4/fb8b3dd8dc0e98edf1135ff067ae070bb32ef9d509d6cb0f538cd6f7483f/cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed", size = 484358, upload-time = "2024-09-04T20:44:40.046Z" }, + { url = "https://files.pythonhosted.org/packages/f1/47/d7145bf2dc04684935d57d67dff9d6d795b2ba2796806bb109864be3a151/cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9", size = 488469, upload-time = "2024-09-04T20:44:41.616Z" }, + { url = "https://files.pythonhosted.org/packages/bf/ee/f94057fa6426481d663b88637a9a10e859e492c73d0384514a17d78ee205/cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d", size = 172475, upload-time = "2024-09-04T20:44:43.733Z" }, + { url = "https://files.pythonhosted.org/packages/7c/fc/6a8cb64e5f0324877d503c854da15d76c1e50eb722e320b15345c4d0c6de/cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a", size = 182009, upload-time = "2024-09-04T20:44:45.309Z" }, +] + +[[package]] +name = "cfgv" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/11/74/539e56497d9bd1d484fd863dd69cbbfa653cd2aa27abfe35653494d85e94/cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560", size = 7114, upload-time = "2023-08-12T20:38:17.776Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/55/51844dd50c4fc7a33b653bfaba4c2456f06955289ca770a5dbd5fd267374/cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9", size = 7249, upload-time = "2023-08-12T20:38:16.269Z" }, +] + +[[package]] +name = "chardet" +version = "5.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/0d/f7b6ab21ec75897ed80c17d79b15951a719226b9fababf1e40ea74d69079/chardet-5.2.0.tar.gz", hash = "sha256:1b3b6ff479a8c414bc3fa2c0852995695c4a026dcd6d0633b2dd092ca39c1cf7", size = 2069618, upload-time = "2023-08-01T19:23:02.662Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/6f/f5fbc992a329ee4e0f288c1fe0e2ad9485ed064cac731ed2fe47dcc38cbf/chardet-5.2.0-py3-none-any.whl", hash = "sha256:e1cf59446890a00105fe7b7912492ea04b6e6f06d4b742b2c788469e34c82970", size = 199385, upload-time = "2023-08-01T19:23:00.661Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/33/89c2ced2b67d1c2a61c19c6751aa8902d46ce3dacb23600a283619f5a12d/charset_normalizer-3.4.2.tar.gz", hash = "sha256:5baececa9ecba31eff645232d59845c07aa030f0c81ee70184a90d35099a0e63", size = 126367, upload-time = "2025-05-02T08:34:42.01Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/28/9901804da60055b406e1a1c5ba7aac1276fb77f1dde635aabfc7fd84b8ab/charset_normalizer-3.4.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7c48ed483eb946e6c04ccbe02c6b4d1d48e51944b6db70f697e089c193404941", size = 201818, upload-time = "2025-05-02T08:31:46.725Z" }, + { url = "https://files.pythonhosted.org/packages/d9/9b/892a8c8af9110935e5adcbb06d9c6fe741b6bb02608c6513983048ba1a18/charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2d318c11350e10662026ad0eb71bb51c7812fc8590825304ae0bdd4ac283acd", size = 144649, upload-time = "2025-05-02T08:31:48.889Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a5/4179abd063ff6414223575e008593861d62abfc22455b5d1a44995b7c101/charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9cbfacf36cb0ec2897ce0ebc5d08ca44213af24265bd56eca54bee7923c48fd6", size = 155045, upload-time = "2025-05-02T08:31:50.757Z" }, + { url = "https://files.pythonhosted.org/packages/3b/95/bc08c7dfeddd26b4be8c8287b9bb055716f31077c8b0ea1cd09553794665/charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:18dd2e350387c87dabe711b86f83c9c78af772c748904d372ade190b5c7c9d4d", size = 147356, upload-time = "2025-05-02T08:31:52.634Z" }, + { url = "https://files.pythonhosted.org/packages/a8/2d/7a5b635aa65284bf3eab7653e8b4151ab420ecbae918d3e359d1947b4d61/charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8075c35cd58273fee266c58c0c9b670947c19df5fb98e7b66710e04ad4e9ff86", size = 149471, upload-time = "2025-05-02T08:31:56.207Z" }, + { url = "https://files.pythonhosted.org/packages/ae/38/51fc6ac74251fd331a8cfdb7ec57beba8c23fd5493f1050f71c87ef77ed0/charset_normalizer-3.4.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5bf4545e3b962767e5c06fe1738f951f77d27967cb2caa64c28be7c4563e162c", size = 151317, upload-time = "2025-05-02T08:31:57.613Z" }, + { url = "https://files.pythonhosted.org/packages/b7/17/edee1e32215ee6e9e46c3e482645b46575a44a2d72c7dfd49e49f60ce6bf/charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:7a6ab32f7210554a96cd9e33abe3ddd86732beeafc7a28e9955cdf22ffadbab0", size = 146368, upload-time = "2025-05-02T08:31:59.468Z" }, + { url = "https://files.pythonhosted.org/packages/26/2c/ea3e66f2b5f21fd00b2825c94cafb8c326ea6240cd80a91eb09e4a285830/charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b33de11b92e9f75a2b545d6e9b6f37e398d86c3e9e9653c4864eb7e89c5773ef", size = 154491, upload-time = "2025-05-02T08:32:01.219Z" }, + { url = "https://files.pythonhosted.org/packages/52/47/7be7fa972422ad062e909fd62460d45c3ef4c141805b7078dbab15904ff7/charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:8755483f3c00d6c9a77f490c17e6ab0c8729e39e6390328e42521ef175380ae6", size = 157695, upload-time = "2025-05-02T08:32:03.045Z" }, + { url = "https://files.pythonhosted.org/packages/2f/42/9f02c194da282b2b340f28e5fb60762de1151387a36842a92b533685c61e/charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:68a328e5f55ec37c57f19ebb1fdc56a248db2e3e9ad769919a58672958e8f366", size = 154849, upload-time = "2025-05-02T08:32:04.651Z" }, + { url = "https://files.pythonhosted.org/packages/67/44/89cacd6628f31fb0b63201a618049be4be2a7435a31b55b5eb1c3674547a/charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:21b2899062867b0e1fde9b724f8aecb1af14f2778d69aacd1a5a1853a597a5db", size = 150091, upload-time = "2025-05-02T08:32:06.719Z" }, + { url = "https://files.pythonhosted.org/packages/1f/79/4b8da9f712bc079c0f16b6d67b099b0b8d808c2292c937f267d816ec5ecc/charset_normalizer-3.4.2-cp310-cp310-win32.whl", hash = "sha256:e8082b26888e2f8b36a042a58307d5b917ef2b1cacab921ad3323ef91901c71a", size = 98445, upload-time = "2025-05-02T08:32:08.66Z" }, + { url = "https://files.pythonhosted.org/packages/7d/d7/96970afb4fb66497a40761cdf7bd4f6fca0fc7bafde3a84f836c1f57a926/charset_normalizer-3.4.2-cp310-cp310-win_amd64.whl", hash = "sha256:f69a27e45c43520f5487f27627059b64aaf160415589230992cec34c5e18a509", size = 105782, upload-time = "2025-05-02T08:32:10.46Z" }, + { url = "https://files.pythonhosted.org/packages/05/85/4c40d00dcc6284a1c1ad5de5e0996b06f39d8232f1031cd23c2f5c07ee86/charset_normalizer-3.4.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:be1e352acbe3c78727a16a455126d9ff83ea2dfdcbc83148d2982305a04714c2", size = 198794, upload-time = "2025-05-02T08:32:11.945Z" }, + { url = "https://files.pythonhosted.org/packages/41/d9/7a6c0b9db952598e97e93cbdfcb91bacd89b9b88c7c983250a77c008703c/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa88ca0b1932e93f2d961bf3addbb2db902198dca337d88c89e1559e066e7645", size = 142846, upload-time = "2025-05-02T08:32:13.946Z" }, + { url = "https://files.pythonhosted.org/packages/66/82/a37989cda2ace7e37f36c1a8ed16c58cf48965a79c2142713244bf945c89/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d524ba3f1581b35c03cb42beebab4a13e6cdad7b36246bd22541fa585a56cccd", size = 153350, upload-time = "2025-05-02T08:32:15.873Z" }, + { url = "https://files.pythonhosted.org/packages/df/68/a576b31b694d07b53807269d05ec3f6f1093e9545e8607121995ba7a8313/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28a1005facc94196e1fb3e82a3d442a9d9110b8434fc1ded7a24a2983c9888d8", size = 145657, upload-time = "2025-05-02T08:32:17.283Z" }, + { url = "https://files.pythonhosted.org/packages/92/9b/ad67f03d74554bed3aefd56fe836e1623a50780f7c998d00ca128924a499/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fdb20a30fe1175ecabed17cbf7812f7b804b8a315a25f24678bcdf120a90077f", size = 147260, upload-time = "2025-05-02T08:32:18.807Z" }, + { url = "https://files.pythonhosted.org/packages/a6/e6/8aebae25e328160b20e31a7e9929b1578bbdc7f42e66f46595a432f8539e/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0f5d9ed7f254402c9e7d35d2f5972c9bbea9040e99cd2861bd77dc68263277c7", size = 149164, upload-time = "2025-05-02T08:32:20.333Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f2/b3c2f07dbcc248805f10e67a0262c93308cfa149a4cd3d1fe01f593e5fd2/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:efd387a49825780ff861998cd959767800d54f8308936b21025326de4b5a42b9", size = 144571, upload-time = "2025-05-02T08:32:21.86Z" }, + { url = "https://files.pythonhosted.org/packages/60/5b/c3f3a94bc345bc211622ea59b4bed9ae63c00920e2e8f11824aa5708e8b7/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:f0aa37f3c979cf2546b73e8222bbfa3dc07a641585340179d768068e3455e544", size = 151952, upload-time = "2025-05-02T08:32:23.434Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4d/ff460c8b474122334c2fa394a3f99a04cf11c646da895f81402ae54f5c42/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e70e990b2137b29dc5564715de1e12701815dacc1d056308e2b17e9095372a82", size = 155959, upload-time = "2025-05-02T08:32:24.993Z" }, + { url = "https://files.pythonhosted.org/packages/a2/2b/b964c6a2fda88611a1fe3d4c400d39c66a42d6c169c924818c848f922415/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:0c8c57f84ccfc871a48a47321cfa49ae1df56cd1d965a09abe84066f6853b9c0", size = 153030, upload-time = "2025-05-02T08:32:26.435Z" }, + { url = "https://files.pythonhosted.org/packages/59/2e/d3b9811db26a5ebf444bc0fa4f4be5aa6d76fc6e1c0fd537b16c14e849b6/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6b66f92b17849b85cad91259efc341dce9c1af48e2173bf38a85c6329f1033e5", size = 148015, upload-time = "2025-05-02T08:32:28.376Z" }, + { url = "https://files.pythonhosted.org/packages/90/07/c5fd7c11eafd561bb51220d600a788f1c8d77c5eef37ee49454cc5c35575/charset_normalizer-3.4.2-cp311-cp311-win32.whl", hash = "sha256:daac4765328a919a805fa5e2720f3e94767abd632ae410a9062dff5412bae65a", size = 98106, upload-time = "2025-05-02T08:32:30.281Z" }, + { url = "https://files.pythonhosted.org/packages/a8/05/5e33dbef7e2f773d672b6d79f10ec633d4a71cd96db6673625838a4fd532/charset_normalizer-3.4.2-cp311-cp311-win_amd64.whl", hash = "sha256:e53efc7c7cee4c1e70661e2e112ca46a575f90ed9ae3fef200f2a25e954f4b28", size = 105402, upload-time = "2025-05-02T08:32:32.191Z" }, + { url = "https://files.pythonhosted.org/packages/d7/a4/37f4d6035c89cac7930395a35cc0f1b872e652eaafb76a6075943754f095/charset_normalizer-3.4.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0c29de6a1a95f24b9a1aa7aefd27d2487263f00dfd55a77719b530788f75cff7", size = 199936, upload-time = "2025-05-02T08:32:33.712Z" }, + { url = "https://files.pythonhosted.org/packages/ee/8a/1a5e33b73e0d9287274f899d967907cd0bf9c343e651755d9307e0dbf2b3/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cddf7bd982eaa998934a91f69d182aec997c6c468898efe6679af88283b498d3", size = 143790, upload-time = "2025-05-02T08:32:35.768Z" }, + { url = "https://files.pythonhosted.org/packages/66/52/59521f1d8e6ab1482164fa21409c5ef44da3e9f653c13ba71becdd98dec3/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fcbe676a55d7445b22c10967bceaaf0ee69407fbe0ece4d032b6eb8d4565982a", size = 153924, upload-time = "2025-05-02T08:32:37.284Z" }, + { url = "https://files.pythonhosted.org/packages/86/2d/fb55fdf41964ec782febbf33cb64be480a6b8f16ded2dbe8db27a405c09f/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d41c4d287cfc69060fa91cae9683eacffad989f1a10811995fa309df656ec214", size = 146626, upload-time = "2025-05-02T08:32:38.803Z" }, + { url = "https://files.pythonhosted.org/packages/8c/73/6ede2ec59bce19b3edf4209d70004253ec5f4e319f9a2e3f2f15601ed5f7/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e594135de17ab3866138f496755f302b72157d115086d100c3f19370839dd3a", size = 148567, upload-time = "2025-05-02T08:32:40.251Z" }, + { url = "https://files.pythonhosted.org/packages/09/14/957d03c6dc343c04904530b6bef4e5efae5ec7d7990a7cbb868e4595ee30/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cf713fe9a71ef6fd5adf7a79670135081cd4431c2943864757f0fa3a65b1fafd", size = 150957, upload-time = "2025-05-02T08:32:41.705Z" }, + { url = "https://files.pythonhosted.org/packages/0d/c8/8174d0e5c10ccebdcb1b53cc959591c4c722a3ad92461a273e86b9f5a302/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a370b3e078e418187da8c3674eddb9d983ec09445c99a3a263c2011993522981", size = 145408, upload-time = "2025-05-02T08:32:43.709Z" }, + { url = "https://files.pythonhosted.org/packages/58/aa/8904b84bc8084ac19dc52feb4f5952c6df03ffb460a887b42615ee1382e8/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a955b438e62efdf7e0b7b52a64dc5c3396e2634baa62471768a64bc2adb73d5c", size = 153399, upload-time = "2025-05-02T08:32:46.197Z" }, + { url = "https://files.pythonhosted.org/packages/c2/26/89ee1f0e264d201cb65cf054aca6038c03b1a0c6b4ae998070392a3ce605/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7222ffd5e4de8e57e03ce2cef95a4c43c98fcb72ad86909abdfc2c17d227fc1b", size = 156815, upload-time = "2025-05-02T08:32:48.105Z" }, + { url = "https://files.pythonhosted.org/packages/fd/07/68e95b4b345bad3dbbd3a8681737b4338ff2c9df29856a6d6d23ac4c73cb/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:bee093bf902e1d8fc0ac143c88902c3dfc8941f7ea1d6a8dd2bcb786d33db03d", size = 154537, upload-time = "2025-05-02T08:32:49.719Z" }, + { url = "https://files.pythonhosted.org/packages/77/1a/5eefc0ce04affb98af07bc05f3bac9094513c0e23b0562d64af46a06aae4/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dedb8adb91d11846ee08bec4c8236c8549ac721c245678282dcb06b221aab59f", size = 149565, upload-time = "2025-05-02T08:32:51.404Z" }, + { url = "https://files.pythonhosted.org/packages/37/a0/2410e5e6032a174c95e0806b1a6585eb21e12f445ebe239fac441995226a/charset_normalizer-3.4.2-cp312-cp312-win32.whl", hash = "sha256:db4c7bf0e07fc3b7d89ac2a5880a6a8062056801b83ff56d8464b70f65482b6c", size = 98357, upload-time = "2025-05-02T08:32:53.079Z" }, + { url = "https://files.pythonhosted.org/packages/6c/4f/c02d5c493967af3eda9c771ad4d2bbc8df6f99ddbeb37ceea6e8716a32bc/charset_normalizer-3.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:5a9979887252a82fefd3d3ed2a8e3b937a7a809f65dcb1e068b090e165bbe99e", size = 105776, upload-time = "2025-05-02T08:32:54.573Z" }, + { url = "https://files.pythonhosted.org/packages/ea/12/a93df3366ed32db1d907d7593a94f1fe6293903e3e92967bebd6950ed12c/charset_normalizer-3.4.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:926ca93accd5d36ccdabd803392ddc3e03e6d4cd1cf17deff3b989ab8e9dbcf0", size = 199622, upload-time = "2025-05-02T08:32:56.363Z" }, + { url = "https://files.pythonhosted.org/packages/04/93/bf204e6f344c39d9937d3c13c8cd5bbfc266472e51fc8c07cb7f64fcd2de/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eba9904b0f38a143592d9fc0e19e2df0fa2e41c3c3745554761c5f6447eedabf", size = 143435, upload-time = "2025-05-02T08:32:58.551Z" }, + { url = "https://files.pythonhosted.org/packages/22/2a/ea8a2095b0bafa6c5b5a55ffdc2f924455233ee7b91c69b7edfcc9e02284/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3fddb7e2c84ac87ac3a947cb4e66d143ca5863ef48e4a5ecb83bd48619e4634e", size = 153653, upload-time = "2025-05-02T08:33:00.342Z" }, + { url = "https://files.pythonhosted.org/packages/b6/57/1b090ff183d13cef485dfbe272e2fe57622a76694061353c59da52c9a659/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98f862da73774290f251b9df8d11161b6cf25b599a66baf087c1ffe340e9bfd1", size = 146231, upload-time = "2025-05-02T08:33:02.081Z" }, + { url = "https://files.pythonhosted.org/packages/e2/28/ffc026b26f441fc67bd21ab7f03b313ab3fe46714a14b516f931abe1a2d8/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c9379d65defcab82d07b2a9dfbfc2e95bc8fe0ebb1b176a3190230a3ef0e07c", size = 148243, upload-time = "2025-05-02T08:33:04.063Z" }, + { url = "https://files.pythonhosted.org/packages/c0/0f/9abe9bd191629c33e69e47c6ef45ef99773320e9ad8e9cb08b8ab4a8d4cb/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e635b87f01ebc977342e2697d05b56632f5f879a4f15955dfe8cef2448b51691", size = 150442, upload-time = "2025-05-02T08:33:06.418Z" }, + { url = "https://files.pythonhosted.org/packages/67/7c/a123bbcedca91d5916c056407f89a7f5e8fdfce12ba825d7d6b9954a1a3c/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1c95a1e2902a8b722868587c0e1184ad5c55631de5afc0eb96bc4b0d738092c0", size = 145147, upload-time = "2025-05-02T08:33:08.183Z" }, + { url = "https://files.pythonhosted.org/packages/ec/fe/1ac556fa4899d967b83e9893788e86b6af4d83e4726511eaaad035e36595/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ef8de666d6179b009dce7bcb2ad4c4a779f113f12caf8dc77f0162c29d20490b", size = 153057, upload-time = "2025-05-02T08:33:09.986Z" }, + { url = "https://files.pythonhosted.org/packages/2b/ff/acfc0b0a70b19e3e54febdd5301a98b72fa07635e56f24f60502e954c461/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:32fc0341d72e0f73f80acb0a2c94216bd704f4f0bce10aedea38f30502b271ff", size = 156454, upload-time = "2025-05-02T08:33:11.814Z" }, + { url = "https://files.pythonhosted.org/packages/92/08/95b458ce9c740d0645feb0e96cea1f5ec946ea9c580a94adfe0b617f3573/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:289200a18fa698949d2b39c671c2cc7a24d44096784e76614899a7ccf2574b7b", size = 154174, upload-time = "2025-05-02T08:33:13.707Z" }, + { url = "https://files.pythonhosted.org/packages/78/be/8392efc43487ac051eee6c36d5fbd63032d78f7728cb37aebcc98191f1ff/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4a476b06fbcf359ad25d34a057b7219281286ae2477cc5ff5e3f70a246971148", size = 149166, upload-time = "2025-05-02T08:33:15.458Z" }, + { url = "https://files.pythonhosted.org/packages/44/96/392abd49b094d30b91d9fbda6a69519e95802250b777841cf3bda8fe136c/charset_normalizer-3.4.2-cp313-cp313-win32.whl", hash = "sha256:aaeeb6a479c7667fbe1099af9617c83aaca22182d6cf8c53966491a0f1b7ffb7", size = 98064, upload-time = "2025-05-02T08:33:17.06Z" }, + { url = "https://files.pythonhosted.org/packages/e9/b0/0200da600134e001d91851ddc797809e2fe0ea72de90e09bec5a2fbdaccb/charset_normalizer-3.4.2-cp313-cp313-win_amd64.whl", hash = "sha256:aa6af9e7d59f9c12b33ae4e9450619cf2488e2bbe9b44030905877f0b2324980", size = 105641, upload-time = "2025-05-02T08:33:18.753Z" }, + { url = "https://files.pythonhosted.org/packages/20/94/c5790835a017658cbfabd07f3bfb549140c3ac458cfc196323996b10095a/charset_normalizer-3.4.2-py3-none-any.whl", hash = "sha256:7f56930ab0abd1c45cd15be65cc741c28b1c9a34876ce8c17a2fa107810c0af0", size = 52626, upload-time = "2025-05-02T08:34:40.053Z" }, +] + +[[package]] +name = "click" +version = "8.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/60/6c/8ca2efa64cf75a977a0d7fac081354553ebe483345c734fb6b6515d96bbc/click-8.2.1.tar.gz", hash = "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202", size = 286342, upload-time = "2025-05-20T23:19:49.832Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/85/32/10bb5764d90a8eee674e9dc6f4db6a0ab47c8c4d0d83c27f7c39ac415a4d/click-8.2.1-py3-none-any.whl", hash = "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b", size = 102215, upload-time = "2025-05-20T23:19:47.796Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "coloredlogs" +version = "15.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "humanfriendly" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cc/c7/eed8f27100517e8c0e6b923d5f0845d0cb99763da6fdee00478f91db7325/coloredlogs-15.0.1.tar.gz", hash = "sha256:7c991aa71a4577af2f82600d8f8f3a89f936baeaf9b50a9c197da014e5bf16b0", size = 278520, upload-time = "2021-06-11T10:22:45.202Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/06/3d6badcf13db419e25b07041d9c7b4a2c331d3f4e7134445ec5df57714cd/coloredlogs-15.0.1-py2.py3-none-any.whl", hash = "sha256:612ee75c546f53e92e70049c9dbfcc18c935a2b9a53b66085ce9ef6a6e5c0934", size = 46018, upload-time = "2021-06-11T10:22:42.561Z" }, +] + +[[package]] +name = "comm" +version = "0.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/a8/fb783cb0abe2b5fded9f55e5703015cdf1c9c85b3669087c538dd15a6a86/comm-0.2.2.tar.gz", hash = "sha256:3fd7a84065306e07bea1773df6eb8282de51ba82f77c72f9c85716ab11fe980e", size = 6210, upload-time = "2024-03-12T16:53:41.133Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/75/49e5bfe642f71f272236b5b2d2691cf915a7283cc0ceda56357b61daa538/comm-0.2.2-py3-none-any.whl", hash = "sha256:e6fb86cb70ff661ee8c9c14e7d36d6de3b4066f1441be4063df9c5009f0a64d3", size = 7180, upload-time = "2024-03-12T16:53:39.226Z" }, +] + +[[package]] +name = "contourpy" +version = "1.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/54/eb9bfc647b19f2009dd5c7f5ec51c4e6ca831725f1aea7a993034f483147/contourpy-1.3.2.tar.gz", hash = "sha256:b6945942715a034c671b7fc54f9588126b0b8bf23db2696e3ca8328f3ff0ab54", size = 13466130, upload-time = "2025-04-15T17:47:53.79Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/a3/da4153ec8fe25d263aa48c1a4cbde7f49b59af86f0b6f7862788c60da737/contourpy-1.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ba38e3f9f330af820c4b27ceb4b9c7feee5fe0493ea53a8720f4792667465934", size = 268551, upload-time = "2025-04-15T17:34:46.581Z" }, + { url = "https://files.pythonhosted.org/packages/2f/6c/330de89ae1087eb622bfca0177d32a7ece50c3ef07b28002de4757d9d875/contourpy-1.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dc41ba0714aa2968d1f8674ec97504a8f7e334f48eeacebcaa6256213acb0989", size = 253399, upload-time = "2025-04-15T17:34:51.427Z" }, + { url = "https://files.pythonhosted.org/packages/c1/bd/20c6726b1b7f81a8bee5271bed5c165f0a8e1f572578a9d27e2ccb763cb2/contourpy-1.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9be002b31c558d1ddf1b9b415b162c603405414bacd6932d031c5b5a8b757f0d", size = 312061, upload-time = "2025-04-15T17:34:55.961Z" }, + { url = "https://files.pythonhosted.org/packages/22/fc/a9665c88f8a2473f823cf1ec601de9e5375050f1958cbb356cdf06ef1ab6/contourpy-1.3.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8d2e74acbcba3bfdb6d9d8384cdc4f9260cae86ed9beee8bd5f54fee49a430b9", size = 351956, upload-time = "2025-04-15T17:35:00.992Z" }, + { url = "https://files.pythonhosted.org/packages/25/eb/9f0a0238f305ad8fb7ef42481020d6e20cf15e46be99a1fcf939546a177e/contourpy-1.3.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e259bced5549ac64410162adc973c5e2fb77f04df4a439d00b478e57a0e65512", size = 320872, upload-time = "2025-04-15T17:35:06.177Z" }, + { url = "https://files.pythonhosted.org/packages/32/5c/1ee32d1c7956923202f00cf8d2a14a62ed7517bdc0ee1e55301227fc273c/contourpy-1.3.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad687a04bc802cbe8b9c399c07162a3c35e227e2daccf1668eb1f278cb698631", size = 325027, upload-time = "2025-04-15T17:35:11.244Z" }, + { url = "https://files.pythonhosted.org/packages/83/bf/9baed89785ba743ef329c2b07fd0611d12bfecbedbdd3eeecf929d8d3b52/contourpy-1.3.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cdd22595308f53ef2f891040ab2b93d79192513ffccbd7fe19be7aa773a5e09f", size = 1306641, upload-time = "2025-04-15T17:35:26.701Z" }, + { url = "https://files.pythonhosted.org/packages/d4/cc/74e5e83d1e35de2d28bd97033426b450bc4fd96e092a1f7a63dc7369b55d/contourpy-1.3.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b4f54d6a2defe9f257327b0f243612dd051cc43825587520b1bf74a31e2f6ef2", size = 1374075, upload-time = "2025-04-15T17:35:43.204Z" }, + { url = "https://files.pythonhosted.org/packages/0c/42/17f3b798fd5e033b46a16f8d9fcb39f1aba051307f5ebf441bad1ecf78f8/contourpy-1.3.2-cp310-cp310-win32.whl", hash = "sha256:f939a054192ddc596e031e50bb13b657ce318cf13d264f095ce9db7dc6ae81c0", size = 177534, upload-time = "2025-04-15T17:35:46.554Z" }, + { url = "https://files.pythonhosted.org/packages/54/ec/5162b8582f2c994721018d0c9ece9dc6ff769d298a8ac6b6a652c307e7df/contourpy-1.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:c440093bbc8fc21c637c03bafcbef95ccd963bc6e0514ad887932c18ca2a759a", size = 221188, upload-time = "2025-04-15T17:35:50.064Z" }, + { url = "https://files.pythonhosted.org/packages/b3/b9/ede788a0b56fc5b071639d06c33cb893f68b1178938f3425debebe2dab78/contourpy-1.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6a37a2fb93d4df3fc4c0e363ea4d16f83195fc09c891bc8ce072b9d084853445", size = 269636, upload-time = "2025-04-15T17:35:54.473Z" }, + { url = "https://files.pythonhosted.org/packages/e6/75/3469f011d64b8bbfa04f709bfc23e1dd71be54d05b1b083be9f5b22750d1/contourpy-1.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b7cd50c38f500bbcc9b6a46643a40e0913673f869315d8e70de0438817cb7773", size = 254636, upload-time = "2025-04-15T17:35:58.283Z" }, + { url = "https://files.pythonhosted.org/packages/8d/2f/95adb8dae08ce0ebca4fd8e7ad653159565d9739128b2d5977806656fcd2/contourpy-1.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d6658ccc7251a4433eebd89ed2672c2ed96fba367fd25ca9512aa92a4b46c4f1", size = 313053, upload-time = "2025-04-15T17:36:03.235Z" }, + { url = "https://files.pythonhosted.org/packages/c3/a6/8ccf97a50f31adfa36917707fe39c9a0cbc24b3bbb58185577f119736cc9/contourpy-1.3.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:70771a461aaeb335df14deb6c97439973d253ae70660ca085eec25241137ef43", size = 352985, upload-time = "2025-04-15T17:36:08.275Z" }, + { url = "https://files.pythonhosted.org/packages/1d/b6/7925ab9b77386143f39d9c3243fdd101621b4532eb126743201160ffa7e6/contourpy-1.3.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:65a887a6e8c4cd0897507d814b14c54a8c2e2aa4ac9f7686292f9769fcf9a6ab", size = 323750, upload-time = "2025-04-15T17:36:13.29Z" }, + { url = "https://files.pythonhosted.org/packages/c2/f3/20c5d1ef4f4748e52d60771b8560cf00b69d5c6368b5c2e9311bcfa2a08b/contourpy-1.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3859783aefa2b8355697f16642695a5b9792e7a46ab86da1118a4a23a51a33d7", size = 326246, upload-time = "2025-04-15T17:36:18.329Z" }, + { url = "https://files.pythonhosted.org/packages/8c/e5/9dae809e7e0b2d9d70c52b3d24cba134dd3dad979eb3e5e71f5df22ed1f5/contourpy-1.3.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:eab0f6db315fa4d70f1d8ab514e527f0366ec021ff853d7ed6a2d33605cf4b83", size = 1308728, upload-time = "2025-04-15T17:36:33.878Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/0058ba34aeea35c0b442ae61a4f4d4ca84d6df8f91309bc2d43bb8dd248f/contourpy-1.3.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d91a3ccc7fea94ca0acab82ceb77f396d50a1f67412efe4c526f5d20264e6ecd", size = 1375762, upload-time = "2025-04-15T17:36:51.295Z" }, + { url = "https://files.pythonhosted.org/packages/09/33/7174bdfc8b7767ef2c08ed81244762d93d5c579336fc0b51ca57b33d1b80/contourpy-1.3.2-cp311-cp311-win32.whl", hash = "sha256:1c48188778d4d2f3d48e4643fb15d8608b1d01e4b4d6b0548d9b336c28fc9b6f", size = 178196, upload-time = "2025-04-15T17:36:55.002Z" }, + { url = "https://files.pythonhosted.org/packages/5e/fe/4029038b4e1c4485cef18e480b0e2cd2d755448bb071eb9977caac80b77b/contourpy-1.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:5ebac872ba09cb8f2131c46b8739a7ff71de28a24c869bcad554477eb089a878", size = 222017, upload-time = "2025-04-15T17:36:58.576Z" }, + { url = "https://files.pythonhosted.org/packages/34/f7/44785876384eff370c251d58fd65f6ad7f39adce4a093c934d4a67a7c6b6/contourpy-1.3.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4caf2bcd2969402bf77edc4cb6034c7dd7c0803213b3523f111eb7460a51b8d2", size = 271580, upload-time = "2025-04-15T17:37:03.105Z" }, + { url = "https://files.pythonhosted.org/packages/93/3b/0004767622a9826ea3d95f0e9d98cd8729015768075d61f9fea8eeca42a8/contourpy-1.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:82199cb78276249796419fe36b7386bd8d2cc3f28b3bc19fe2454fe2e26c4c15", size = 255530, upload-time = "2025-04-15T17:37:07.026Z" }, + { url = "https://files.pythonhosted.org/packages/e7/bb/7bd49e1f4fa805772d9fd130e0d375554ebc771ed7172f48dfcd4ca61549/contourpy-1.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:106fab697af11456fcba3e352ad50effe493a90f893fca6c2ca5c033820cea92", size = 307688, upload-time = "2025-04-15T17:37:11.481Z" }, + { url = "https://files.pythonhosted.org/packages/fc/97/e1d5dbbfa170725ef78357a9a0edc996b09ae4af170927ba8ce977e60a5f/contourpy-1.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d14f12932a8d620e307f715857107b1d1845cc44fdb5da2bc8e850f5ceba9f87", size = 347331, upload-time = "2025-04-15T17:37:18.212Z" }, + { url = "https://files.pythonhosted.org/packages/6f/66/e69e6e904f5ecf6901be3dd16e7e54d41b6ec6ae3405a535286d4418ffb4/contourpy-1.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:532fd26e715560721bb0d5fc7610fce279b3699b018600ab999d1be895b09415", size = 318963, upload-time = "2025-04-15T17:37:22.76Z" }, + { url = "https://files.pythonhosted.org/packages/a8/32/b8a1c8965e4f72482ff2d1ac2cd670ce0b542f203c8e1d34e7c3e6925da7/contourpy-1.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f26b383144cf2d2c29f01a1e8170f50dacf0eac02d64139dcd709a8ac4eb3cfe", size = 323681, upload-time = "2025-04-15T17:37:33.001Z" }, + { url = "https://files.pythonhosted.org/packages/30/c6/12a7e6811d08757c7162a541ca4c5c6a34c0f4e98ef2b338791093518e40/contourpy-1.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c49f73e61f1f774650a55d221803b101d966ca0c5a2d6d5e4320ec3997489441", size = 1308674, upload-time = "2025-04-15T17:37:48.64Z" }, + { url = "https://files.pythonhosted.org/packages/2a/8a/bebe5a3f68b484d3a2b8ffaf84704b3e343ef1addea528132ef148e22b3b/contourpy-1.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3d80b2c0300583228ac98d0a927a1ba6a2ba6b8a742463c564f1d419ee5b211e", size = 1380480, upload-time = "2025-04-15T17:38:06.7Z" }, + { url = "https://files.pythonhosted.org/packages/34/db/fcd325f19b5978fb509a7d55e06d99f5f856294c1991097534360b307cf1/contourpy-1.3.2-cp312-cp312-win32.whl", hash = "sha256:90df94c89a91b7362e1142cbee7568f86514412ab8a2c0d0fca72d7e91b62912", size = 178489, upload-time = "2025-04-15T17:38:10.338Z" }, + { url = "https://files.pythonhosted.org/packages/01/c8/fadd0b92ffa7b5eb5949bf340a63a4a496a6930a6c37a7ba0f12acb076d6/contourpy-1.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:8c942a01d9163e2e5cfb05cb66110121b8d07ad438a17f9e766317bcb62abf73", size = 223042, upload-time = "2025-04-15T17:38:14.239Z" }, + { url = "https://files.pythonhosted.org/packages/2e/61/5673f7e364b31e4e7ef6f61a4b5121c5f170f941895912f773d95270f3a2/contourpy-1.3.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:de39db2604ae755316cb5967728f4bea92685884b1e767b7c24e983ef5f771cb", size = 271630, upload-time = "2025-04-15T17:38:19.142Z" }, + { url = "https://files.pythonhosted.org/packages/ff/66/a40badddd1223822c95798c55292844b7e871e50f6bfd9f158cb25e0bd39/contourpy-1.3.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3f9e896f447c5c8618f1edb2bafa9a4030f22a575ec418ad70611450720b5b08", size = 255670, upload-time = "2025-04-15T17:38:23.688Z" }, + { url = "https://files.pythonhosted.org/packages/1e/c7/cf9fdee8200805c9bc3b148f49cb9482a4e3ea2719e772602a425c9b09f8/contourpy-1.3.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:71e2bd4a1c4188f5c2b8d274da78faab884b59df20df63c34f74aa1813c4427c", size = 306694, upload-time = "2025-04-15T17:38:28.238Z" }, + { url = "https://files.pythonhosted.org/packages/dd/e7/ccb9bec80e1ba121efbffad7f38021021cda5be87532ec16fd96533bb2e0/contourpy-1.3.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de425af81b6cea33101ae95ece1f696af39446db9682a0b56daaa48cfc29f38f", size = 345986, upload-time = "2025-04-15T17:38:33.502Z" }, + { url = "https://files.pythonhosted.org/packages/dc/49/ca13bb2da90391fa4219fdb23b078d6065ada886658ac7818e5441448b78/contourpy-1.3.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:977e98a0e0480d3fe292246417239d2d45435904afd6d7332d8455981c408b85", size = 318060, upload-time = "2025-04-15T17:38:38.672Z" }, + { url = "https://files.pythonhosted.org/packages/c8/65/5245ce8c548a8422236c13ffcdcdada6a2a812c361e9e0c70548bb40b661/contourpy-1.3.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:434f0adf84911c924519d2b08fc10491dd282b20bdd3fa8f60fd816ea0b48841", size = 322747, upload-time = "2025-04-15T17:38:43.712Z" }, + { url = "https://files.pythonhosted.org/packages/72/30/669b8eb48e0a01c660ead3752a25b44fdb2e5ebc13a55782f639170772f9/contourpy-1.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c66c4906cdbc50e9cba65978823e6e00b45682eb09adbb78c9775b74eb222422", size = 1308895, upload-time = "2025-04-15T17:39:00.224Z" }, + { url = "https://files.pythonhosted.org/packages/05/5a/b569f4250decee6e8d54498be7bdf29021a4c256e77fe8138c8319ef8eb3/contourpy-1.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8b7fc0cd78ba2f4695fd0a6ad81a19e7e3ab825c31b577f384aa9d7817dc3bef", size = 1379098, upload-time = "2025-04-15T17:43:29.649Z" }, + { url = "https://files.pythonhosted.org/packages/19/ba/b227c3886d120e60e41b28740ac3617b2f2b971b9f601c835661194579f1/contourpy-1.3.2-cp313-cp313-win32.whl", hash = "sha256:15ce6ab60957ca74cff444fe66d9045c1fd3e92c8936894ebd1f3eef2fff075f", size = 178535, upload-time = "2025-04-15T17:44:44.532Z" }, + { url = "https://files.pythonhosted.org/packages/12/6e/2fed56cd47ca739b43e892707ae9a13790a486a3173be063681ca67d2262/contourpy-1.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:e1578f7eafce927b168752ed7e22646dad6cd9bca673c60bff55889fa236ebf9", size = 223096, upload-time = "2025-04-15T17:44:48.194Z" }, + { url = "https://files.pythonhosted.org/packages/54/4c/e76fe2a03014a7c767d79ea35c86a747e9325537a8b7627e0e5b3ba266b4/contourpy-1.3.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0475b1f6604896bc7c53bb070e355e9321e1bc0d381735421a2d2068ec56531f", size = 285090, upload-time = "2025-04-15T17:43:34.084Z" }, + { url = "https://files.pythonhosted.org/packages/7b/e2/5aba47debd55d668e00baf9651b721e7733975dc9fc27264a62b0dd26eb8/contourpy-1.3.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:c85bb486e9be652314bb5b9e2e3b0d1b2e643d5eec4992c0fbe8ac71775da739", size = 268643, upload-time = "2025-04-15T17:43:38.626Z" }, + { url = "https://files.pythonhosted.org/packages/a1/37/cd45f1f051fe6230f751cc5cdd2728bb3a203f5619510ef11e732109593c/contourpy-1.3.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:745b57db7758f3ffc05a10254edd3182a2a83402a89c00957a8e8a22f5582823", size = 310443, upload-time = "2025-04-15T17:43:44.522Z" }, + { url = "https://files.pythonhosted.org/packages/8b/a2/36ea6140c306c9ff6dd38e3bcec80b3b018474ef4d17eb68ceecd26675f4/contourpy-1.3.2-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:970e9173dbd7eba9b4e01aab19215a48ee5dd3f43cef736eebde064a171f89a5", size = 349865, upload-time = "2025-04-15T17:43:49.545Z" }, + { url = "https://files.pythonhosted.org/packages/95/b7/2fc76bc539693180488f7b6cc518da7acbbb9e3b931fd9280504128bf956/contourpy-1.3.2-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c6c4639a9c22230276b7bffb6a850dfc8258a2521305e1faefe804d006b2e532", size = 321162, upload-time = "2025-04-15T17:43:54.203Z" }, + { url = "https://files.pythonhosted.org/packages/f4/10/76d4f778458b0aa83f96e59d65ece72a060bacb20cfbee46cf6cd5ceba41/contourpy-1.3.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc829960f34ba36aad4302e78eabf3ef16a3a100863f0d4eeddf30e8a485a03b", size = 327355, upload-time = "2025-04-15T17:44:01.025Z" }, + { url = "https://files.pythonhosted.org/packages/43/a3/10cf483ea683f9f8ab096c24bad3cce20e0d1dd9a4baa0e2093c1c962d9d/contourpy-1.3.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d32530b534e986374fc19eaa77fcb87e8a99e5431499949b828312bdcd20ac52", size = 1307935, upload-time = "2025-04-15T17:44:17.322Z" }, + { url = "https://files.pythonhosted.org/packages/78/73/69dd9a024444489e22d86108e7b913f3528f56cfc312b5c5727a44188471/contourpy-1.3.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:e298e7e70cf4eb179cc1077be1c725b5fd131ebc81181bf0c03525c8abc297fd", size = 1372168, upload-time = "2025-04-15T17:44:33.43Z" }, + { url = "https://files.pythonhosted.org/packages/0f/1b/96d586ccf1b1a9d2004dd519b25fbf104a11589abfd05484ff12199cca21/contourpy-1.3.2-cp313-cp313t-win32.whl", hash = "sha256:d0e589ae0d55204991450bb5c23f571c64fe43adaa53f93fc902a84c96f52fe1", size = 189550, upload-time = "2025-04-15T17:44:37.092Z" }, + { url = "https://files.pythonhosted.org/packages/b0/e6/6000d0094e8a5e32ad62591c8609e269febb6e4db83a1c75ff8868b42731/contourpy-1.3.2-cp313-cp313t-win_amd64.whl", hash = "sha256:78e9253c3de756b3f6a5174d024c4835acd59eb3f8e2ca13e775dbffe1558f69", size = 238214, upload-time = "2025-04-15T17:44:40.827Z" }, + { url = "https://files.pythonhosted.org/packages/33/05/b26e3c6ecc05f349ee0013f0bb850a761016d89cec528a98193a48c34033/contourpy-1.3.2-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:fd93cc7f3139b6dd7aab2f26a90dde0aa9fc264dbf70f6740d498a70b860b82c", size = 265681, upload-time = "2025-04-15T17:44:59.314Z" }, + { url = "https://files.pythonhosted.org/packages/2b/25/ac07d6ad12affa7d1ffed11b77417d0a6308170f44ff20fa1d5aa6333f03/contourpy-1.3.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:107ba8a6a7eec58bb475329e6d3b95deba9440667c4d62b9b6063942b61d7f16", size = 315101, upload-time = "2025-04-15T17:45:04.165Z" }, + { url = "https://files.pythonhosted.org/packages/8f/4d/5bb3192bbe9d3f27e3061a6a8e7733c9120e203cb8515767d30973f71030/contourpy-1.3.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:ded1706ed0c1049224531b81128efbd5084598f18d8a2d9efae833edbd2b40ad", size = 220599, upload-time = "2025-04-15T17:45:08.456Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c0/91f1215d0d9f9f343e4773ba6c9b89e8c0cc7a64a6263f21139da639d848/contourpy-1.3.2-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:5f5964cdad279256c084b69c3f412b7801e15356b16efa9d78aa974041903da0", size = 266807, upload-time = "2025-04-15T17:45:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/d4/79/6be7e90c955c0487e7712660d6cead01fa17bff98e0ea275737cc2bc8e71/contourpy-1.3.2-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49b65a95d642d4efa8f64ba12558fcb83407e58a2dfba9d796d77b63ccfcaff5", size = 318729, upload-time = "2025-04-15T17:45:20.166Z" }, + { url = "https://files.pythonhosted.org/packages/87/68/7f46fb537958e87427d98a4074bcde4b67a70b04900cfc5ce29bc2f556c1/contourpy-1.3.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:8c5acb8dddb0752bf252e01a3035b21443158910ac16a3b0d20e7fed7d534ce5", size = 221791, upload-time = "2025-04-15T17:45:24.794Z" }, +] + +[[package]] +name = "coverage" +version = "7.8.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/07/998afa4a0ecdf9b1981ae05415dad2d4e7716e1b1f00abbd91691ac09ac9/coverage-7.8.2.tar.gz", hash = "sha256:a886d531373a1f6ff9fad2a2ba4a045b68467b779ae729ee0b3b10ac20033b27", size = 812759, upload-time = "2025-05-23T11:39:57.856Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/6b/7dd06399a5c0b81007e3a6af0395cd60e6a30f959f8d407d3ee04642e896/coverage-7.8.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bd8ec21e1443fd7a447881332f7ce9d35b8fbd2849e761bb290b584535636b0a", size = 211573, upload-time = "2025-05-23T11:37:47.207Z" }, + { url = "https://files.pythonhosted.org/packages/f0/df/2b24090820a0bac1412955fb1a4dade6bc3b8dcef7b899c277ffaf16916d/coverage-7.8.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4c26c2396674816deaeae7ded0e2b42c26537280f8fe313335858ffff35019be", size = 212006, upload-time = "2025-05-23T11:37:50.289Z" }, + { url = "https://files.pythonhosted.org/packages/c5/c4/e4e3b998e116625562a872a342419652fa6ca73f464d9faf9f52f1aff427/coverage-7.8.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1aec326ed237e5880bfe69ad41616d333712c7937bcefc1343145e972938f9b3", size = 241128, upload-time = "2025-05-23T11:37:52.229Z" }, + { url = "https://files.pythonhosted.org/packages/b1/67/b28904afea3e87a895da850ba587439a61699bf4b73d04d0dfd99bbd33b4/coverage-7.8.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5e818796f71702d7a13e50c70de2a1924f729228580bcba1607cccf32eea46e6", size = 239026, upload-time = "2025-05-23T11:37:53.846Z" }, + { url = "https://files.pythonhosted.org/packages/8c/0f/47bf7c5630d81bc2cd52b9e13043685dbb7c79372a7f5857279cc442b37c/coverage-7.8.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:546e537d9e24efc765c9c891328f30f826e3e4808e31f5d0f87c4ba12bbd1622", size = 240172, upload-time = "2025-05-23T11:37:55.711Z" }, + { url = "https://files.pythonhosted.org/packages/ba/38/af3eb9d36d85abc881f5aaecf8209383dbe0fa4cac2d804c55d05c51cb04/coverage-7.8.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ab9b09a2349f58e73f8ebc06fac546dd623e23b063e5398343c5270072e3201c", size = 240086, upload-time = "2025-05-23T11:37:57.724Z" }, + { url = "https://files.pythonhosted.org/packages/9e/64/c40c27c2573adeba0fe16faf39a8aa57368a1f2148865d6bb24c67eadb41/coverage-7.8.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:fd51355ab8a372d89fb0e6a31719e825cf8df8b6724bee942fb5b92c3f016ba3", size = 238792, upload-time = "2025-05-23T11:37:59.737Z" }, + { url = "https://files.pythonhosted.org/packages/8e/ab/b7c85146f15457671c1412afca7c25a5696d7625e7158002aa017e2d7e3c/coverage-7.8.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:0774df1e093acb6c9e4d58bce7f86656aeed6c132a16e2337692c12786b32404", size = 239096, upload-time = "2025-05-23T11:38:01.693Z" }, + { url = "https://files.pythonhosted.org/packages/d3/50/9446dad1310905fb1dc284d60d4320a5b25d4e3e33f9ea08b8d36e244e23/coverage-7.8.2-cp310-cp310-win32.whl", hash = "sha256:00f2e2f2e37f47e5f54423aeefd6c32a7dbcedc033fcd3928a4f4948e8b96af7", size = 214144, upload-time = "2025-05-23T11:38:03.68Z" }, + { url = "https://files.pythonhosted.org/packages/23/ed/792e66ad7b8b0df757db8d47af0c23659cdb5a65ef7ace8b111cacdbee89/coverage-7.8.2-cp310-cp310-win_amd64.whl", hash = "sha256:145b07bea229821d51811bf15eeab346c236d523838eda395ea969d120d13347", size = 215043, upload-time = "2025-05-23T11:38:05.217Z" }, + { url = "https://files.pythonhosted.org/packages/6a/4d/1ff618ee9f134d0de5cc1661582c21a65e06823f41caf801aadf18811a8e/coverage-7.8.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b99058eef42e6a8dcd135afb068b3d53aff3921ce699e127602efff9956457a9", size = 211692, upload-time = "2025-05-23T11:38:08.485Z" }, + { url = "https://files.pythonhosted.org/packages/96/fa/c3c1b476de96f2bc7a8ca01a9f1fcb51c01c6b60a9d2c3e66194b2bdb4af/coverage-7.8.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5feb7f2c3e6ea94d3b877def0270dff0947b8d8c04cfa34a17be0a4dc1836879", size = 212115, upload-time = "2025-05-23T11:38:09.989Z" }, + { url = "https://files.pythonhosted.org/packages/f7/c2/5414c5a1b286c0f3881ae5adb49be1854ac5b7e99011501f81c8c1453065/coverage-7.8.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:670a13249b957bb9050fab12d86acef7bf8f6a879b9d1a883799276e0d4c674a", size = 244740, upload-time = "2025-05-23T11:38:11.947Z" }, + { url = "https://files.pythonhosted.org/packages/cd/46/1ae01912dfb06a642ef3dd9cf38ed4996fda8fe884dab8952da616f81a2b/coverage-7.8.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0bdc8bf760459a4a4187b452213e04d039990211f98644c7292adf1e471162b5", size = 242429, upload-time = "2025-05-23T11:38:13.955Z" }, + { url = "https://files.pythonhosted.org/packages/06/58/38c676aec594bfe2a87c7683942e5a30224791d8df99bcc8439fde140377/coverage-7.8.2-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:07a989c867986c2a75f158f03fdb413128aad29aca9d4dbce5fc755672d96f11", size = 244218, upload-time = "2025-05-23T11:38:15.631Z" }, + { url = "https://files.pythonhosted.org/packages/80/0c/95b1023e881ce45006d9abc250f76c6cdab7134a1c182d9713878dfefcb2/coverage-7.8.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2db10dedeb619a771ef0e2949ccba7b75e33905de959c2643a4607bef2f3fb3a", size = 243865, upload-time = "2025-05-23T11:38:17.622Z" }, + { url = "https://files.pythonhosted.org/packages/57/37/0ae95989285a39e0839c959fe854a3ae46c06610439350d1ab860bf020ac/coverage-7.8.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e6ea7dba4e92926b7b5f0990634b78ea02f208d04af520c73a7c876d5a8d36cb", size = 242038, upload-time = "2025-05-23T11:38:19.966Z" }, + { url = "https://files.pythonhosted.org/packages/4d/82/40e55f7c0eb5e97cc62cbd9d0746fd24e8caf57be5a408b87529416e0c70/coverage-7.8.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ef2f22795a7aca99fc3c84393a55a53dd18ab8c93fb431004e4d8f0774150f54", size = 242567, upload-time = "2025-05-23T11:38:21.912Z" }, + { url = "https://files.pythonhosted.org/packages/f9/35/66a51adc273433a253989f0d9cc7aa6bcdb4855382cf0858200afe578861/coverage-7.8.2-cp311-cp311-win32.whl", hash = "sha256:641988828bc18a6368fe72355df5f1703e44411adbe49bba5644b941ce6f2e3a", size = 214194, upload-time = "2025-05-23T11:38:23.571Z" }, + { url = "https://files.pythonhosted.org/packages/f6/8f/a543121f9f5f150eae092b08428cb4e6b6d2d134152c3357b77659d2a605/coverage-7.8.2-cp311-cp311-win_amd64.whl", hash = "sha256:8ab4a51cb39dc1933ba627e0875046d150e88478dbe22ce145a68393e9652975", size = 215109, upload-time = "2025-05-23T11:38:25.137Z" }, + { url = "https://files.pythonhosted.org/packages/77/65/6cc84b68d4f35186463cd7ab1da1169e9abb59870c0f6a57ea6aba95f861/coverage-7.8.2-cp311-cp311-win_arm64.whl", hash = "sha256:8966a821e2083c74d88cca5b7dcccc0a3a888a596a04c0b9668a891de3a0cc53", size = 213521, upload-time = "2025-05-23T11:38:27.123Z" }, + { url = "https://files.pythonhosted.org/packages/8d/2a/1da1ada2e3044fcd4a3254fb3576e160b8fe5b36d705c8a31f793423f763/coverage-7.8.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e2f6fe3654468d061942591aef56686131335b7a8325684eda85dacdf311356c", size = 211876, upload-time = "2025-05-23T11:38:29.01Z" }, + { url = "https://files.pythonhosted.org/packages/70/e9/3d715ffd5b6b17a8be80cd14a8917a002530a99943cc1939ad5bb2aa74b9/coverage-7.8.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:76090fab50610798cc05241bf83b603477c40ee87acd358b66196ab0ca44ffa1", size = 212130, upload-time = "2025-05-23T11:38:30.675Z" }, + { url = "https://files.pythonhosted.org/packages/a0/02/fdce62bb3c21649abfd91fbdcf041fb99be0d728ff00f3f9d54d97ed683e/coverage-7.8.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2bd0a0a5054be160777a7920b731a0570284db5142abaaf81bcbb282b8d99279", size = 246176, upload-time = "2025-05-23T11:38:32.395Z" }, + { url = "https://files.pythonhosted.org/packages/a7/52/decbbed61e03b6ffe85cd0fea360a5e04a5a98a7423f292aae62423b8557/coverage-7.8.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:da23ce9a3d356d0affe9c7036030b5c8f14556bd970c9b224f9c8205505e3b99", size = 243068, upload-time = "2025-05-23T11:38:33.989Z" }, + { url = "https://files.pythonhosted.org/packages/38/6c/d0e9c0cce18faef79a52778219a3c6ee8e336437da8eddd4ab3dbd8fadff/coverage-7.8.2-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c9392773cffeb8d7e042a7b15b82a414011e9d2b5fdbbd3f7e6a6b17d5e21b20", size = 245328, upload-time = "2025-05-23T11:38:35.568Z" }, + { url = "https://files.pythonhosted.org/packages/f0/70/f703b553a2f6b6c70568c7e398ed0789d47f953d67fbba36a327714a7bca/coverage-7.8.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:876cbfd0b09ce09d81585d266c07a32657beb3eaec896f39484b631555be0fe2", size = 245099, upload-time = "2025-05-23T11:38:37.627Z" }, + { url = "https://files.pythonhosted.org/packages/ec/fb/4cbb370dedae78460c3aacbdad9d249e853f3bc4ce5ff0e02b1983d03044/coverage-7.8.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:3da9b771c98977a13fbc3830f6caa85cae6c9c83911d24cb2d218e9394259c57", size = 243314, upload-time = "2025-05-23T11:38:39.238Z" }, + { url = "https://files.pythonhosted.org/packages/39/9f/1afbb2cb9c8699b8bc38afdce00a3b4644904e6a38c7bf9005386c9305ec/coverage-7.8.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:9a990f6510b3292686713bfef26d0049cd63b9c7bb17e0864f133cbfd2e6167f", size = 244489, upload-time = "2025-05-23T11:38:40.845Z" }, + { url = "https://files.pythonhosted.org/packages/79/fa/f3e7ec7d220bff14aba7a4786ae47043770cbdceeea1803083059c878837/coverage-7.8.2-cp312-cp312-win32.whl", hash = "sha256:bf8111cddd0f2b54d34e96613e7fbdd59a673f0cf5574b61134ae75b6f5a33b8", size = 214366, upload-time = "2025-05-23T11:38:43.551Z" }, + { url = "https://files.pythonhosted.org/packages/54/aa/9cbeade19b7e8e853e7ffc261df885d66bf3a782c71cba06c17df271f9e6/coverage-7.8.2-cp312-cp312-win_amd64.whl", hash = "sha256:86a323a275e9e44cdf228af9b71c5030861d4d2610886ab920d9945672a81223", size = 215165, upload-time = "2025-05-23T11:38:45.148Z" }, + { url = "https://files.pythonhosted.org/packages/c4/73/e2528bf1237d2448f882bbebaec5c3500ef07301816c5c63464b9da4d88a/coverage-7.8.2-cp312-cp312-win_arm64.whl", hash = "sha256:820157de3a589e992689ffcda8639fbabb313b323d26388d02e154164c57b07f", size = 213548, upload-time = "2025-05-23T11:38:46.74Z" }, + { url = "https://files.pythonhosted.org/packages/1a/93/eb6400a745ad3b265bac36e8077fdffcf0268bdbbb6c02b7220b624c9b31/coverage-7.8.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ea561010914ec1c26ab4188aef8b1567272ef6de096312716f90e5baa79ef8ca", size = 211898, upload-time = "2025-05-23T11:38:49.066Z" }, + { url = "https://files.pythonhosted.org/packages/1b/7c/bdbf113f92683024406a1cd226a199e4200a2001fc85d6a6e7e299e60253/coverage-7.8.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cb86337a4fcdd0e598ff2caeb513ac604d2f3da6d53df2c8e368e07ee38e277d", size = 212171, upload-time = "2025-05-23T11:38:51.207Z" }, + { url = "https://files.pythonhosted.org/packages/91/22/594513f9541a6b88eb0dba4d5da7d71596dadef6b17a12dc2c0e859818a9/coverage-7.8.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:26a4636ddb666971345541b59899e969f3b301143dd86b0ddbb570bd591f1e85", size = 245564, upload-time = "2025-05-23T11:38:52.857Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f4/2860fd6abeebd9f2efcfe0fd376226938f22afc80c1943f363cd3c28421f/coverage-7.8.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5040536cf9b13fb033f76bcb5e1e5cb3b57c4807fef37db9e0ed129c6a094257", size = 242719, upload-time = "2025-05-23T11:38:54.529Z" }, + { url = "https://files.pythonhosted.org/packages/89/60/f5f50f61b6332451520e6cdc2401700c48310c64bc2dd34027a47d6ab4ca/coverage-7.8.2-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc67994df9bcd7e0150a47ef41278b9e0a0ea187caba72414b71dc590b99a108", size = 244634, upload-time = "2025-05-23T11:38:57.326Z" }, + { url = "https://files.pythonhosted.org/packages/3b/70/7f4e919039ab7d944276c446b603eea84da29ebcf20984fb1fdf6e602028/coverage-7.8.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6e6c86888fd076d9e0fe848af0a2142bf606044dc5ceee0aa9eddb56e26895a0", size = 244824, upload-time = "2025-05-23T11:38:59.421Z" }, + { url = "https://files.pythonhosted.org/packages/26/45/36297a4c0cea4de2b2c442fe32f60c3991056c59cdc3cdd5346fbb995c97/coverage-7.8.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:684ca9f58119b8e26bef860db33524ae0365601492e86ba0b71d513f525e7050", size = 242872, upload-time = "2025-05-23T11:39:01.049Z" }, + { url = "https://files.pythonhosted.org/packages/a4/71/e041f1b9420f7b786b1367fa2a375703889ef376e0d48de9f5723fb35f11/coverage-7.8.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8165584ddedb49204c4e18da083913bdf6a982bfb558632a79bdaadcdafd0d48", size = 244179, upload-time = "2025-05-23T11:39:02.709Z" }, + { url = "https://files.pythonhosted.org/packages/bd/db/3c2bf49bdc9de76acf2491fc03130c4ffc51469ce2f6889d2640eb563d77/coverage-7.8.2-cp313-cp313-win32.whl", hash = "sha256:34759ee2c65362163699cc917bdb2a54114dd06d19bab860725f94ef45a3d9b7", size = 214393, upload-time = "2025-05-23T11:39:05.457Z" }, + { url = "https://files.pythonhosted.org/packages/c6/dc/947e75d47ebbb4b02d8babb1fad4ad381410d5bc9da7cfca80b7565ef401/coverage-7.8.2-cp313-cp313-win_amd64.whl", hash = "sha256:2f9bc608fbafaee40eb60a9a53dbfb90f53cc66d3d32c2849dc27cf5638a21e3", size = 215194, upload-time = "2025-05-23T11:39:07.171Z" }, + { url = "https://files.pythonhosted.org/packages/90/31/a980f7df8a37eaf0dc60f932507fda9656b3a03f0abf188474a0ea188d6d/coverage-7.8.2-cp313-cp313-win_arm64.whl", hash = "sha256:9fe449ee461a3b0c7105690419d0b0aba1232f4ff6d120a9e241e58a556733f7", size = 213580, upload-time = "2025-05-23T11:39:08.862Z" }, + { url = "https://files.pythonhosted.org/packages/8a/6a/25a37dd90f6c95f59355629417ebcb74e1c34e38bb1eddf6ca9b38b0fc53/coverage-7.8.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:8369a7c8ef66bded2b6484053749ff220dbf83cba84f3398c84c51a6f748a008", size = 212734, upload-time = "2025-05-23T11:39:11.109Z" }, + { url = "https://files.pythonhosted.org/packages/36/8b/3a728b3118988725f40950931abb09cd7f43b3c740f4640a59f1db60e372/coverage-7.8.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:159b81df53a5fcbc7d45dae3adad554fdbde9829a994e15227b3f9d816d00b36", size = 212959, upload-time = "2025-05-23T11:39:12.751Z" }, + { url = "https://files.pythonhosted.org/packages/53/3c/212d94e6add3a3c3f412d664aee452045ca17a066def8b9421673e9482c4/coverage-7.8.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e6fcbbd35a96192d042c691c9e0c49ef54bd7ed865846a3c9d624c30bb67ce46", size = 257024, upload-time = "2025-05-23T11:39:15.569Z" }, + { url = "https://files.pythonhosted.org/packages/a4/40/afc03f0883b1e51bbe804707aae62e29c4e8c8bbc365c75e3e4ddeee9ead/coverage-7.8.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:05364b9cc82f138cc86128dc4e2e1251c2981a2218bfcd556fe6b0fbaa3501be", size = 252867, upload-time = "2025-05-23T11:39:17.64Z" }, + { url = "https://files.pythonhosted.org/packages/18/a2/3699190e927b9439c6ded4998941a3c1d6fa99e14cb28d8536729537e307/coverage-7.8.2-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:46d532db4e5ff3979ce47d18e2fe8ecad283eeb7367726da0e5ef88e4fe64740", size = 255096, upload-time = "2025-05-23T11:39:19.328Z" }, + { url = "https://files.pythonhosted.org/packages/b4/06/16e3598b9466456b718eb3e789457d1a5b8bfb22e23b6e8bbc307df5daf0/coverage-7.8.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4000a31c34932e7e4fa0381a3d6deb43dc0c8f458e3e7ea6502e6238e10be625", size = 256276, upload-time = "2025-05-23T11:39:21.077Z" }, + { url = "https://files.pythonhosted.org/packages/a7/d5/4b5a120d5d0223050a53d2783c049c311eea1709fa9de12d1c358e18b707/coverage-7.8.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:43ff5033d657cd51f83015c3b7a443287250dc14e69910577c3e03bd2e06f27b", size = 254478, upload-time = "2025-05-23T11:39:22.838Z" }, + { url = "https://files.pythonhosted.org/packages/ba/85/f9ecdb910ecdb282b121bfcaa32fa8ee8cbd7699f83330ee13ff9bbf1a85/coverage-7.8.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:94316e13f0981cbbba132c1f9f365cac1d26716aaac130866ca812006f662199", size = 255255, upload-time = "2025-05-23T11:39:24.644Z" }, + { url = "https://files.pythonhosted.org/packages/50/63/2d624ac7d7ccd4ebbd3c6a9eba9d7fc4491a1226071360d59dd84928ccb2/coverage-7.8.2-cp313-cp313t-win32.whl", hash = "sha256:3f5673888d3676d0a745c3d0e16da338c5eea300cb1f4ada9c872981265e76d8", size = 215109, upload-time = "2025-05-23T11:39:26.722Z" }, + { url = "https://files.pythonhosted.org/packages/22/5e/7053b71462e970e869111c1853afd642212568a350eba796deefdfbd0770/coverage-7.8.2-cp313-cp313t-win_amd64.whl", hash = "sha256:2c08b05ee8d7861e45dc5a2cc4195c8c66dca5ac613144eb6ebeaff2d502e73d", size = 216268, upload-time = "2025-05-23T11:39:28.429Z" }, + { url = "https://files.pythonhosted.org/packages/07/69/afa41aa34147655543dbe96994f8a246daf94b361ccf5edfd5df62ce066a/coverage-7.8.2-cp313-cp313t-win_arm64.whl", hash = "sha256:1e1448bb72b387755e1ff3ef1268a06617afd94188164960dba8d0245a46004b", size = 214071, upload-time = "2025-05-23T11:39:30.55Z" }, + { url = "https://files.pythonhosted.org/packages/69/2f/572b29496d8234e4a7773200dd835a0d32d9e171f2d974f3fe04a9dbc271/coverage-7.8.2-pp39.pp310.pp311-none-any.whl", hash = "sha256:ec455eedf3ba0bbdf8f5a570012617eb305c63cb9f03428d39bf544cb2b94837", size = 203636, upload-time = "2025-05-23T11:39:52.002Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1a/0b9c32220ad694d66062f571cc5cedfa9997b64a591e8a500bb63de1bd40/coverage-7.8.2-py3-none-any.whl", hash = "sha256:726f32ee3713f7359696331a18daf0c3b3a70bb0ae71141b9d3c52be7c595e32", size = 203623, upload-time = "2025-05-23T11:39:53.846Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + +[[package]] +name = "cycler" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a9/95/a3dbbb5028f35eafb79008e7522a75244477d2838f38cbb722248dabc2a8/cycler-0.12.1.tar.gz", hash = "sha256:88bb128f02ba341da8ef447245a9e138fae777f6a23943da4540077d3601eb1c", size = 7615, upload-time = "2023-10-07T05:32:18.335Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321, upload-time = "2023-10-07T05:32:16.783Z" }, +] + +[[package]] +name = "debugpy" +version = "1.8.14" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/75/087fe07d40f490a78782ff3b0a30e3968936854105487decdb33446d4b0e/debugpy-1.8.14.tar.gz", hash = "sha256:7cd287184318416850aa8b60ac90105837bb1e59531898c07569d197d2ed5322", size = 1641444, upload-time = "2025-04-10T19:46:10.981Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/df/156df75a41aaebd97cee9d3870fe68f8001b6c1c4ca023e221cfce69bece/debugpy-1.8.14-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:93fee753097e85623cab1c0e6a68c76308cd9f13ffdf44127e6fab4fbf024339", size = 2076510, upload-time = "2025-04-10T19:46:13.315Z" }, + { url = "https://files.pythonhosted.org/packages/69/cd/4fc391607bca0996db5f3658762106e3d2427beaef9bfd363fd370a3c054/debugpy-1.8.14-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d937d93ae4fa51cdc94d3e865f535f185d5f9748efb41d0d49e33bf3365bd79", size = 3559614, upload-time = "2025-04-10T19:46:14.647Z" }, + { url = "https://files.pythonhosted.org/packages/1a/42/4e6d2b9d63e002db79edfd0cb5656f1c403958915e0e73ab3e9220012eec/debugpy-1.8.14-cp310-cp310-win32.whl", hash = "sha256:c442f20577b38cc7a9aafecffe1094f78f07fb8423c3dddb384e6b8f49fd2987", size = 5208588, upload-time = "2025-04-10T19:46:16.233Z" }, + { url = "https://files.pythonhosted.org/packages/97/b1/cc9e4e5faadc9d00df1a64a3c2d5c5f4b9df28196c39ada06361c5141f89/debugpy-1.8.14-cp310-cp310-win_amd64.whl", hash = "sha256:f117dedda6d969c5c9483e23f573b38f4e39412845c7bc487b6f2648df30fe84", size = 5241043, upload-time = "2025-04-10T19:46:17.768Z" }, + { url = "https://files.pythonhosted.org/packages/67/e8/57fe0c86915671fd6a3d2d8746e40485fd55e8d9e682388fbb3a3d42b86f/debugpy-1.8.14-cp311-cp311-macosx_14_0_universal2.whl", hash = "sha256:1b2ac8c13b2645e0b1eaf30e816404990fbdb168e193322be8f545e8c01644a9", size = 2175064, upload-time = "2025-04-10T19:46:19.486Z" }, + { url = "https://files.pythonhosted.org/packages/3b/97/2b2fd1b1c9569c6764ccdb650a6f752e4ac31be465049563c9eb127a8487/debugpy-1.8.14-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cf431c343a99384ac7eab2f763980724834f933a271e90496944195318c619e2", size = 3132359, upload-time = "2025-04-10T19:46:21.192Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ee/b825c87ed06256ee2a7ed8bab8fb3bb5851293bf9465409fdffc6261c426/debugpy-1.8.14-cp311-cp311-win32.whl", hash = "sha256:c99295c76161ad8d507b413cd33422d7c542889fbb73035889420ac1fad354f2", size = 5133269, upload-time = "2025-04-10T19:46:23.047Z" }, + { url = "https://files.pythonhosted.org/packages/d5/a6/6c70cd15afa43d37839d60f324213843174c1d1e6bb616bd89f7c1341bac/debugpy-1.8.14-cp311-cp311-win_amd64.whl", hash = "sha256:7816acea4a46d7e4e50ad8d09d963a680ecc814ae31cdef3622eb05ccacf7b01", size = 5158156, upload-time = "2025-04-10T19:46:24.521Z" }, + { url = "https://files.pythonhosted.org/packages/d9/2a/ac2df0eda4898f29c46eb6713a5148e6f8b2b389c8ec9e425a4a1d67bf07/debugpy-1.8.14-cp312-cp312-macosx_14_0_universal2.whl", hash = "sha256:8899c17920d089cfa23e6005ad9f22582fd86f144b23acb9feeda59e84405b84", size = 2501268, upload-time = "2025-04-10T19:46:26.044Z" }, + { url = "https://files.pythonhosted.org/packages/10/53/0a0cb5d79dd9f7039169f8bf94a144ad3efa52cc519940b3b7dde23bcb89/debugpy-1.8.14-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f6bb5c0dcf80ad5dbc7b7d6eac484e2af34bdacdf81df09b6a3e62792b722826", size = 4221077, upload-time = "2025-04-10T19:46:27.464Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d5/84e01821f362327bf4828728aa31e907a2eca7c78cd7c6ec062780d249f8/debugpy-1.8.14-cp312-cp312-win32.whl", hash = "sha256:281d44d248a0e1791ad0eafdbbd2912ff0de9eec48022a5bfbc332957487ed3f", size = 5255127, upload-time = "2025-04-10T19:46:29.467Z" }, + { url = "https://files.pythonhosted.org/packages/33/16/1ed929d812c758295cac7f9cf3dab5c73439c83d9091f2d91871e648093e/debugpy-1.8.14-cp312-cp312-win_amd64.whl", hash = "sha256:5aa56ef8538893e4502a7d79047fe39b1dae08d9ae257074c6464a7b290b806f", size = 5297249, upload-time = "2025-04-10T19:46:31.538Z" }, + { url = "https://files.pythonhosted.org/packages/4d/e4/395c792b243f2367d84202dc33689aa3d910fb9826a7491ba20fc9e261f5/debugpy-1.8.14-cp313-cp313-macosx_14_0_universal2.whl", hash = "sha256:329a15d0660ee09fec6786acdb6e0443d595f64f5d096fc3e3ccf09a4259033f", size = 2485676, upload-time = "2025-04-10T19:46:32.96Z" }, + { url = "https://files.pythonhosted.org/packages/ba/f1/6f2ee3f991327ad9e4c2f8b82611a467052a0fb0e247390192580e89f7ff/debugpy-1.8.14-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f920c7f9af409d90f5fd26e313e119d908b0dd2952c2393cd3247a462331f15", size = 4217514, upload-time = "2025-04-10T19:46:34.336Z" }, + { url = "https://files.pythonhosted.org/packages/79/28/b9d146f8f2dc535c236ee09ad3e5ac899adb39d7a19b49f03ac95d216beb/debugpy-1.8.14-cp313-cp313-win32.whl", hash = "sha256:3784ec6e8600c66cbdd4ca2726c72d8ca781e94bce2f396cc606d458146f8f4e", size = 5254756, upload-time = "2025-04-10T19:46:36.199Z" }, + { url = "https://files.pythonhosted.org/packages/e0/62/a7b4a57013eac4ccaef6977966e6bec5c63906dd25a86e35f155952e29a1/debugpy-1.8.14-cp313-cp313-win_amd64.whl", hash = "sha256:684eaf43c95a3ec39a96f1f5195a7ff3d4144e4a18d69bb66beeb1a6de605d6e", size = 5297119, upload-time = "2025-04-10T19:46:38.141Z" }, + { url = "https://files.pythonhosted.org/packages/97/1a/481f33c37ee3ac8040d3d51fc4c4e4e7e61cb08b8bc8971d6032acc2279f/debugpy-1.8.14-py2.py3-none-any.whl", hash = "sha256:5cd9a579d553b6cb9759a7908a41988ee6280b961f24f63336835d9418216a20", size = 5256230, upload-time = "2025-04-10T19:46:54.077Z" }, +] + +[[package]] +name = "decorator" +version = "5.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/fa/6d96a0978d19e17b68d634497769987b16c8f4cd0a7a05048bec693caa6b/decorator-5.2.1.tar.gz", hash = "sha256:65f266143752f734b0a7cc83c46f4618af75b8c5911b00ccb61d0ac9b6da0360", size = 56711, upload-time = "2025-02-24T04:41:34.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/8c/f3147f5c4b73e7550fe5f9352eaa956ae838d5c51eb58e7a25b9f3e2643b/decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a", size = 9190, upload-time = "2025-02-24T04:41:32.565Z" }, +] + +[[package]] +name = "defusedxml" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/d5/c66da9b79e5bdb124974bfe172b4daf3c984ebd9c2a06e2b8a4dc7331c72/defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", size = 75520, upload-time = "2021-03-08T10:59:26.269Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" }, +] + +[[package]] +name = "distlib" +version = "0.3.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0d/dd/1bec4c5ddb504ca60fc29472f3d27e8d4da1257a854e1d96742f15c1d02d/distlib-0.3.9.tar.gz", hash = "sha256:a60f20dea646b8a33f3e7772f74dc0b2d0772d2837ee1342a00645c81edf9403", size = 613923, upload-time = "2024-10-09T18:35:47.551Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/a1/cf2472db20f7ce4a6be1253a81cfdf85ad9c7885ffbed7047fb72c24cf87/distlib-0.3.9-py2.py3-none-any.whl", hash = "sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87", size = 468973, upload-time = "2024-10-09T18:35:44.272Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749, upload-time = "2025-05-10T17:42:51.123Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/36/f4/c6e662dade71f56cd2f3735141b265c3c79293c109549c1e6933b0651ffc/exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10", size = 16674, upload-time = "2025-05-10T17:42:49.33Z" }, +] + +[[package]] +name = "executing" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/91/50/a9d80c47ff289c611ff12e63f7c5d13942c65d68125160cefd768c73e6e4/executing-2.2.0.tar.gz", hash = "sha256:5d108c028108fe2551d1a7b2e8b713341e2cb4fc0aa7dcf966fa4327a5226755", size = 978693, upload-time = "2025-01-22T15:41:29.403Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/8f/c4d9bafc34ad7ad5d8dc16dd1347ee0e507a52c3adb6bfa8887e1c6a26ba/executing-2.2.0-py2.py3-none-any.whl", hash = "sha256:11387150cad388d62750327a53d3339fad4888b39a6fe233c3afbb54ecffd3aa", size = 26702, upload-time = "2025-01-22T15:41:25.929Z" }, +] + +[[package]] +name = "fastapi" +version = "0.115.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f4/55/ae499352d82338331ca1e28c7f4a63bfd09479b16395dce38cf50a39e2c2/fastapi-0.115.12.tar.gz", hash = "sha256:1e2c2a2646905f9e83d32f04a3f86aff4a286669c6c950ca95b5fd68c2602681", size = 295236, upload-time = "2025-03-23T22:55:43.822Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/50/b3/b51f09c2ba432a576fe63758bddc81f78f0c6309d9e5c10d194313bf021e/fastapi-0.115.12-py3-none-any.whl", hash = "sha256:e94613d6c05e27be7ffebdd6ea5f388112e5e430c8f7d6494a9d1d88d43e814d", size = 95164, upload-time = "2025-03-23T22:55:42.101Z" }, +] + +[[package]] +name = "faster-coco-eval" +version = "1.6.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "pandas" }, + { name = "pillow" }, + { name = "plotly" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/54/cf/d0b4ef6d3c8f22cbb6f6d78dc924a0602cf96d991d8eaf94978ac1e2df7c/faster_coco_eval-1.6.6.tar.gz", hash = "sha256:6e3225b64244503e91f1f7d385b8b24ddb9d9dca9b83f248bab7546228fa5b87", size = 69682, upload-time = "2025-05-22T08:52:42.936Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/2a/dfab46da6d7192221f9eee97cc3f2eb74bdd091d8908303c8c0cb56b3e27/faster_coco_eval-1.6.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f77aef6de891437cb2571a529240bb8f7c0234185d377e793e0fe41e903b3614", size = 369913, upload-time = "2025-05-22T08:51:56.795Z" }, + { url = "https://files.pythonhosted.org/packages/30/9e/3e76d070e05a7c56fad5771fb5bad519ae91fbee37020e65e0e6d89c1a86/faster_coco_eval-1.6.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd48f0a87fd3a8f50cfca9b61a7017ec38697e9d2b5c15d9f84388fc6225bcaa", size = 345409, upload-time = "2025-05-22T08:51:58.026Z" }, + { url = "https://files.pythonhosted.org/packages/a8/15/da97ecfc651bcb85ac0b29cee2cf74200e82ecf7116b65def78055133fa4/faster_coco_eval-1.6.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3ee11ba34237dc1ed1db900311eb4a64fe9827132d5bda7fcfd2babb96e0c541", size = 457111, upload-time = "2025-05-22T08:51:59.204Z" }, + { url = "https://files.pythonhosted.org/packages/d4/39/b4814e1ad01792d7de818e7eeec27bcfe8b34ec4cfa999587ab1545e9b92/faster_coco_eval-1.6.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:771ffa3e64ff6226304107319282c0eb6b35d95ef062aa183e2e0a3cea03aeb6", size = 476462, upload-time = "2025-05-22T08:52:00.22Z" }, + { url = "https://files.pythonhosted.org/packages/81/41/81f4bc5e700eb213398cb3e7c2ab5d56e93debd9d47a300526c40d1fc81a/faster_coco_eval-1.6.6-cp310-cp310-win_amd64.whl", hash = "sha256:abb1440cd31f3d111f6c002c7f6796906082e46757aa2a6dde7cd8a26a131b82", size = 311169, upload-time = "2025-05-22T08:52:01.685Z" }, + { url = "https://files.pythonhosted.org/packages/79/3f/0e27f2a4a02c589ba260edf443d231e194a1dd22f9f5aa2873faac117ca2/faster_coco_eval-1.6.6-cp310-cp310-win_arm64.whl", hash = "sha256:f6b68d559ff0e27d2a45f914828264227efc27e87b0db6a28eb017b52d869945", size = 297929, upload-time = "2025-05-22T08:52:02.652Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ef/4bf3cd0d18ccd74ab3f39c39933c16a0e723a934f527d93cda906d0035a7/faster_coco_eval-1.6.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1d3f5a2e27d214f33bbe52530ccfff4376d9a31dd460837b51b266f3b5f5828b", size = 372228, upload-time = "2025-05-22T08:52:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/01/46/6f70b852f69c38bf97ba0e6ab0f73bb98ab79de9b553849d46ae8bb80899/faster_coco_eval-1.6.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e2c33f2995b27017db197ea8b894ec5ad026eae95a028787a955658746537adc", size = 348613, upload-time = "2025-05-22T08:52:05.003Z" }, + { url = "https://files.pythonhosted.org/packages/54/46/bbaade5beda42f666aa9bf83e768529d97a4c25532fdc8cdecd7e6ad0dd9/faster_coco_eval-1.6.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:10026856311b6757df044601185005b4577c8972fe9064994f61ff2c55de20fb", size = 459847, upload-time = "2025-05-22T08:52:06.067Z" }, + { url = "https://files.pythonhosted.org/packages/2f/ab/f67bd8f03715d1f83390985a52aca3eea69727b5bec8b8cae6cc4fe315f9/faster_coco_eval-1.6.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c48274e0b91b35acee542af06b7e4efde4413055ba22e9cda4e65fbb9312938", size = 479403, upload-time = "2025-05-22T08:52:07.539Z" }, + { url = "https://files.pythonhosted.org/packages/e0/03/a3a3b4cc18bd97e52c8e1c1b7617be00d12d4cef76a728603d0f984880b9/faster_coco_eval-1.6.6-cp311-cp311-win_amd64.whl", hash = "sha256:7aaf8773853d7497f9b6a7da3c5dec16e90610ac2ee33e67f9dec29eb1574e3d", size = 313471, upload-time = "2025-05-22T08:52:08.758Z" }, + { url = "https://files.pythonhosted.org/packages/60/32/1a5967154ce4982dfcdc841614d11b4ba4e4063764012fefad1ea9c6d8e6/faster_coco_eval-1.6.6-cp311-cp311-win_arm64.whl", hash = "sha256:b5cfdd22b85b39c39843b3f40dac09ce0665719cca8e727bf10dd48b8af23d96", size = 299225, upload-time = "2025-05-22T08:52:10.145Z" }, + { url = "https://files.pythonhosted.org/packages/5b/2c/8ed03e3a698ee9da34e0fb145987947d7ab808c6f7f7fc6b718303f65aaf/faster_coco_eval-1.6.6-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:b6cef59312210b0a98ceb7ac64c3d96c32e69f37856eabb8ae7d8f80ca18a3f1", size = 373171, upload-time = "2025-05-22T08:52:11.377Z" }, + { url = "https://files.pythonhosted.org/packages/96/d8/c6d5b600faaaf68564310b2dff8cea05af9f5480744414f1435a993a17a3/faster_coco_eval-1.6.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ec23f767a16ad845bb4f8e2fb1830d3e892b4fc8fe8bf45b87829198b4846c13", size = 348154, upload-time = "2025-05-22T08:52:12.36Z" }, + { url = "https://files.pythonhosted.org/packages/0b/ef/53e8aea721ee0300fb4f49640da931b54a469e7e4fcb3dfed0d208a8d1d6/faster_coco_eval-1.6.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab151514702541c6ce2b3f71a565e48997d1d8e1b63518411030917fae6d4fca", size = 458144, upload-time = "2025-05-22T08:52:13.332Z" }, + { url = "https://files.pythonhosted.org/packages/de/ac/af6e4845bc0e9a91386b80a4ba7d5bfece8cd5c3e38d896ad089d023e811/faster_coco_eval-1.6.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:53aaae0648687ac9fc7bd33182e8f4f972db3aeb386925fc4afe50a811f11759", size = 478524, upload-time = "2025-05-22T08:52:14.692Z" }, + { url = "https://files.pythonhosted.org/packages/41/39/ce95a92f29047862061958bf09ba09cc18f6d0a2cb17adff14453b9014f0/faster_coco_eval-1.6.6-cp312-cp312-win_amd64.whl", hash = "sha256:d836ddaba2d2afe9ed145898f2f428e62cb6728befe0996e956f659a33107638", size = 314517, upload-time = "2025-05-22T08:52:15.665Z" }, + { url = "https://files.pythonhosted.org/packages/40/a3/0d77e747d20ab28a30ea2a466a55766933a54b0fca091ca56740a39f352f/faster_coco_eval-1.6.6-cp312-cp312-win_arm64.whl", hash = "sha256:fa686251b00376aaf8136b0ba693cee29c8fc5dbb988565f4efc39cda6396139", size = 296889, upload-time = "2025-05-22T08:52:16.579Z" }, + { url = "https://files.pythonhosted.org/packages/23/4b/2c43bdc6f82385d327ac1b598480564f62d4501c6a0b97e0e1e0c3c90ec5/faster_coco_eval-1.6.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:37e8e96a67ef147d130d013aa1141d16abc29e0b199c4ccc738577268ac3d5fb", size = 373265, upload-time = "2025-05-22T08:52:17.556Z" }, + { url = "https://files.pythonhosted.org/packages/ca/f5/60af79384b1df6cacc839c9dac07e650d54e58b2c35caa29a1345da3fbb5/faster_coco_eval-1.6.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1f2f5ebce127a1fadd2749f2edb8574c890419f6e318fca46682e5d8373e0935", size = 348320, upload-time = "2025-05-22T08:52:18.993Z" }, + { url = "https://files.pythonhosted.org/packages/e5/7c/3ae6104959e29859f0d131224ecbf106c9f9ab5d645d2556fb1d7f27d32f/faster_coco_eval-1.6.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4a5daac296848aee0f71834fc338e46d966dfeb84d8729d7920586c42fa1c5b9", size = 458460, upload-time = "2025-05-22T08:52:20.037Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b1/9c2da48cbbe501a176ac1648c32d5b3c777774a740b0b2f826fb1828e012/faster_coco_eval-1.6.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d6f4436069eee42209a7d2cfea93e7cab1795d851eae22b4976bc398c460265c", size = 478368, upload-time = "2025-05-22T08:52:21.492Z" }, + { url = "https://files.pythonhosted.org/packages/4d/d4/f537889c744a34d727322ff63e88d1dc8a20e682dc36880fa5fbae071a53/faster_coco_eval-1.6.6-cp313-cp313-win_amd64.whl", hash = "sha256:a22612f75bf8f03cfc36d3cc8d3f6b753b23a736dffad4a54ccde7b56a415523", size = 314567, upload-time = "2025-05-22T08:52:22.445Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a8/91ba69c9cf28c6e7ec27422b655b797bc1f77495c670166682df2a46beeb/faster_coco_eval-1.6.6-cp313-cp313-win_arm64.whl", hash = "sha256:fa19abe6b90199a589b4d2c0fe294a581e88deef856d1d094a425c74c4792365", size = 296806, upload-time = "2025-05-22T08:52:23.47Z" }, +] + +[[package]] +name = "ffmpy" +version = "0.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4d/66/5697a7421c418ccbfae87b7e6503b480070f7cb16c25c77201afc6246348/ffmpy-0.5.0.tar.gz", hash = "sha256:277e131f246d18e9dcfee9bb514c50749031c43582ce5ef82c57b51e3d3955c3", size = 5523, upload-time = "2024-12-19T15:52:24.69Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/53/5d/65f40bd333463b3230b3a72d93873caaf49b0cbb5228598fafb75fcc5357/ffmpy-0.5.0-py3-none-any.whl", hash = "sha256:df3799cf5816daa56d4959a023630ee53c6768b66009dae6d131519ba4b80233", size = 6008, upload-time = "2024-12-19T15:52:22.416Z" }, +] + +[[package]] +name = "filelock" +version = "3.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0a/10/c23352565a6544bdc5353e0b15fc1c563352101f30e24bf500207a54df9a/filelock-3.18.0.tar.gz", hash = "sha256:adbc88eabb99d2fec8c9c1b229b171f18afa655400173ddc653d5d01501fb9f2", size = 18075, upload-time = "2025-03-14T07:11:40.47Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/36/2a115987e2d8c300a974597416d9de88f2444426de9571f4b59b2cca3acc/filelock-3.18.0-py3-none-any.whl", hash = "sha256:c401f4f8377c4464e6db25fff06205fd89bdd83b65eb0488ed1b160f780e21de", size = 16215, upload-time = "2025-03-14T07:11:39.145Z" }, +] + +[[package]] +name = "flatbuffers" +version = "25.2.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/30/eb5dce7994fc71a2f685d98ec33cc660c0a5887db5610137e60d8cbc4489/flatbuffers-25.2.10.tar.gz", hash = "sha256:97e451377a41262f8d9bd4295cc836133415cc03d8cb966410a4af92eb00d26e", size = 22170, upload-time = "2025-02-11T04:26:46.257Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/25/155f9f080d5e4bc0082edfda032ea2bc2b8fab3f4d25d46c1e9dd22a1a89/flatbuffers-25.2.10-py2.py3-none-any.whl", hash = "sha256:ebba5f4d5ea615af3f7fd70fc310636fbb2bbd1f566ac0a23d98dd412de50051", size = 30953, upload-time = "2025-02-11T04:26:44.484Z" }, +] + +[[package]] +name = "focoos" +version = "0.15.0" +source = { editable = "." } +dependencies = [ + { name = "colorama" }, + { name = "faster-coco-eval" }, + { name = "fvcore" }, + { name = "gradio" }, + { name = "ipython" }, + { name = "matplotlib" }, + { name = "numpy" }, + { name = "opencv-python" }, + { name = "orjson" }, + { name = "pillow" }, + { name = "psutil" }, + { name = "pycocotools" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "requests" }, + { name = "scipy" }, + { name = "shapely" }, + { name = "supervision" }, + { name = "tensorboard" }, + { name = "torch" }, + { name = "torchvision" }, + { name = "tqdm" }, +] + +[package.optional-dependencies] +dev = [ + { name = "ipykernel" }, + { name = "pre-commit" }, + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "pytest-mock" }, + { name = "python-dotenv" }, + { name = "ruff" }, + { name = "sniffio" }, + { name = "tox" }, + { name = "tox-uv" }, +] +docs = [ + { name = "mkdocs" }, + { name = "mkdocs-include-markdown-plugin" }, + { name = "mkdocs-material" }, + { name = "mkdocstrings", extra = ["python"] }, +] +onnx = [ + { name = "onnx" }, + { name = "onnxruntime-gpu" }, + { name = "onnxscript" }, + { name = "onnxslim" }, +] +onnx-cpu = [ + { name = "onnx" }, + { name = "onnxruntime" }, + { name = "onnxscript" }, + { name = "onnxslim" }, +] +tensorrt = [ + { name = "tensorrt" }, +] + +[package.metadata] +requires-dist = [ + { name = "colorama", specifier = "~=0.4.6" }, + { name = "faster-coco-eval", specifier = "~=1.6.6" }, + { name = "fvcore", specifier = "~=0.1.4" }, + { name = "gradio", specifier = "~=5.31.0" }, + { name = "ipykernel", marker = "extra == 'dev'", specifier = "~=6.29.5" }, + { name = "ipython" }, + { name = "matplotlib", specifier = "~=3.10.1" }, + { name = "mkdocs", marker = "extra == 'docs'", specifier = ">=1.6.0,<2.0.0" }, + { name = "mkdocs-include-markdown-plugin", marker = "extra == 'docs'", specifier = ">=6.2.1,<7.0.0" }, + { name = "mkdocs-material", marker = "extra == 'docs'", specifier = ">=9.5.28,<10.0.0" }, + { name = "mkdocstrings", extras = ["python"], marker = "extra == 'docs'", specifier = ">=0.29.0,<0.30.0" }, + { name = "numpy", specifier = "~=2.2.1" }, + { name = "onnx", marker = "extra == 'onnx'", specifier = ">=1.17.0" }, + { name = "onnx", marker = "extra == 'onnx-cpu'", specifier = ">=1.18.0" }, + { name = "onnxruntime", marker = "extra == 'onnx-cpu'", specifier = "==1.22.0" }, + { name = "onnxruntime-gpu", marker = "extra == 'onnx'", specifier = "==1.22.0" }, + { name = "onnxscript", marker = "extra == 'onnx'", specifier = "~=0.2.7" }, + { name = "onnxscript", marker = "extra == 'onnx-cpu'", specifier = "~=0.2.7" }, + { name = "onnxslim", marker = "extra == 'onnx'", specifier = "~=0.1.54" }, + { name = "onnxslim", marker = "extra == 'onnx-cpu'", specifier = "~=0.1.54" }, + { name = "opencv-python", specifier = "~=4.11.0.86" }, + { name = "orjson", specifier = "~=3.10.18" }, + { name = "pillow", specifier = "~=10.2.0" }, + { name = "pre-commit", marker = "extra == 'dev'", specifier = "~=4.2.0" }, + { name = "psutil", specifier = "~=7.0.0" }, + { name = "pycocotools", specifier = "~=2.0.8" }, + { name = "pydantic", specifier = "~=2.11.4" }, + { name = "pydantic-settings", specifier = "~=2.8.1" }, + { name = "pytest", marker = "extra == 'dev'" }, + { name = "pytest-cov", marker = "extra == 'dev'" }, + { name = "pytest-mock", marker = "extra == 'dev'" }, + { name = "python-dotenv", marker = "extra == 'dev'" }, + { name = "requests" }, + { name = "ruff", marker = "extra == 'dev'" }, + { name = "scipy", specifier = "~=1.14.1" }, + { name = "shapely", specifier = "~=2.1.0" }, + { name = "sniffio", marker = "extra == 'dev'", specifier = "~=1.3.1" }, + { name = "supervision", specifier = "~=0.26.0rc7" }, + { name = "tensorboard", specifier = "~=2.19.0" }, + { name = "tensorrt", marker = "extra == 'tensorrt'", specifier = "==10.5.0" }, + { name = "torch", specifier = "~=2.7.0" }, + { name = "torchvision", specifier = "~=0.22.0" }, + { name = "tox", marker = "extra == 'dev'" }, + { name = "tox-uv", marker = "extra == 'dev'" }, + { name = "tqdm", specifier = "~=4.67.1" }, +] +provides-extras = ["tensorrt", "onnx", "onnx-cpu", "dev", "docs"] + +[[package]] +name = "fonttools" +version = "4.58.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/cf/4d037663e2a1fe30fddb655d755d76e18624be44ad467c07412c2319ab97/fonttools-4.58.0.tar.gz", hash = "sha256:27423d0606a2c7b336913254bf0b1193ebd471d5f725d665e875c5e88a011a43", size = 3514522, upload-time = "2025-05-10T17:36:35.886Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/72/07/06d01b7239d6632a0984ef29ab496928531862b827cd3aa78309b205850d/fonttools-4.58.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:0bcaa65cddbc7d32c77bd0af0b41fdd6448bad0e84365ca79cf8923c27b21e46", size = 2731632, upload-time = "2025-05-10T17:34:55.331Z" }, + { url = "https://files.pythonhosted.org/packages/1d/c7/47d26d48d779b1b084ebc0d9ec07035167992578768237ef553a3eecc8db/fonttools-4.58.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:25590272f89e94ab5a292d518c549f3a88e6a34fa1193797b7047dfea111b048", size = 2303941, upload-time = "2025-05-10T17:34:58.624Z" }, + { url = "https://files.pythonhosted.org/packages/79/2e/ac80c0fea501f1aa93e2b22d72c97a8c0d14239582b7e8c722185a0540a7/fonttools-4.58.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:614435e9a87abe18bd7bc7ceeb8029e8f181c571317161e89fa3e6e0a4f20f5d", size = 4712776, upload-time = "2025-05-10T17:35:01.124Z" }, + { url = "https://files.pythonhosted.org/packages/f2/5c/b41f9c940dc397ecb41765654efc76e06782bfe0783c3e2affc534be181c/fonttools-4.58.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0154bd86d9a9e880f6e937e4d99c2139a624428dd9852072e12d7a85c79d611e", size = 4743251, upload-time = "2025-05-10T17:35:03.815Z" }, + { url = "https://files.pythonhosted.org/packages/3d/c4/0d3807d922a788b603a3fff622af53e732464b88baf0049a181a90f9b1c6/fonttools-4.58.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5b3660df0b02c9cebbf7baf66952c2fd055e43e658aceb92cc95ba19e0a5c8b6", size = 4795635, upload-time = "2025-05-10T17:35:06.134Z" }, + { url = "https://files.pythonhosted.org/packages/46/74/627bed8e2c7e641c9c572f09970b0980e5513fd29e57b394d4aee2261e30/fonttools-4.58.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c43b7f1d0b818427bb1cd20903d1168271abdcde10eb6247b1995c4e1ed63907", size = 4904720, upload-time = "2025-05-10T17:35:09.015Z" }, + { url = "https://files.pythonhosted.org/packages/f9/f2/7e5d082a98eb61fc0c3055e8a0e061a1eb9fc2d93f0661854bf6cb63c519/fonttools-4.58.0-cp310-cp310-win32.whl", hash = "sha256:5450f40c385cdfa21133245f57b9cf8ce45018a04630a98de61eed8da14b8325", size = 2188180, upload-time = "2025-05-10T17:35:11.494Z" }, + { url = "https://files.pythonhosted.org/packages/00/33/ffd914e3c3a585003d770457188c8eaf7266b7a1cceb6d234ab543a9f958/fonttools-4.58.0-cp310-cp310-win_amd64.whl", hash = "sha256:c0553431696eacafee9aefe94dc3c2bf5d658fbdc7fdba5b341c588f935471c6", size = 2233120, upload-time = "2025-05-10T17:35:13.896Z" }, + { url = "https://files.pythonhosted.org/packages/76/2e/9b9bd943872a50cb182382f8f4a99af92d76e800603d5f73e4343fdce61a/fonttools-4.58.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9345b1bb994476d6034996b31891c0c728c1059c05daa59f9ab57d2a4dce0f84", size = 2751920, upload-time = "2025-05-10T17:35:16.487Z" }, + { url = "https://files.pythonhosted.org/packages/9b/8c/e8d6375da893125f610826c2e30e6d2597dfb8dad256f8ff5a54f3089fda/fonttools-4.58.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1d93119ace1e2d39ff1340deb71097932f72b21c054bd3da727a3859825e24e5", size = 2313957, upload-time = "2025-05-10T17:35:18.906Z" }, + { url = "https://files.pythonhosted.org/packages/4f/1b/a29cb00c8c20164b24f88780e298fafd0bbfb25cf8bc7b10c4b69331ad5d/fonttools-4.58.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:79c9e4f01bb04f19df272ae35314eb6349fdb2e9497a163cd22a21be999694bd", size = 4913808, upload-time = "2025-05-10T17:35:21.394Z" }, + { url = "https://files.pythonhosted.org/packages/d1/ab/9b9507b65b15190cbfe1ccd3c08067d79268d8312ef20948b16d9f5aa905/fonttools-4.58.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:62ecda1465d38248aaf9bee1c17a21cf0b16aef7d121d7d303dbb320a6fd49c2", size = 4935876, upload-time = "2025-05-10T17:35:23.849Z" }, + { url = "https://files.pythonhosted.org/packages/15/e4/1395853bc775b0ab06a1c61cf261779afda7baff3f65cf1197bbd21aa149/fonttools-4.58.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:29d0499bff12a26733c05c1bfd07e68465158201624b2fba4a40b23d96c43f94", size = 4974798, upload-time = "2025-05-10T17:35:26.189Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b9/0358368ef5462f4653a198207b29885bee8d5e23c870f6125450ed88e693/fonttools-4.58.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1871abdb0af582e2d96cc12d88889e3bfa796928f491ec14d34a2e58ca298c7e", size = 5093560, upload-time = "2025-05-10T17:35:28.577Z" }, + { url = "https://files.pythonhosted.org/packages/11/00/f64bc3659980c41eccf2c371e62eb15b40858f02a41a0e9c6258ef094388/fonttools-4.58.0-cp311-cp311-win32.whl", hash = "sha256:e292485d70402093eb94f6ab7669221743838b8bd4c1f45c84ca76b63338e7bf", size = 2186330, upload-time = "2025-05-10T17:35:31.733Z" }, + { url = "https://files.pythonhosted.org/packages/c8/a0/0287be13a1ec7733abf292ffbd76417cea78752d4ce10fecf92d8b1252d6/fonttools-4.58.0-cp311-cp311-win_amd64.whl", hash = "sha256:6df3755fcf9ad70a74ad3134bd5c9738f73c9bb701a304b1c809877b11fe701c", size = 2234687, upload-time = "2025-05-10T17:35:34.015Z" }, + { url = "https://files.pythonhosted.org/packages/6a/4e/1c6b35ec7c04d739df4cf5aace4b7ec284d6af2533a65de21972e2f237d9/fonttools-4.58.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:aa8316798f982c751d71f0025b372151ea36405733b62d0d94d5e7b8dd674fa6", size = 2737502, upload-time = "2025-05-10T17:35:36.436Z" }, + { url = "https://files.pythonhosted.org/packages/fc/72/c6fcafa3c9ed2b69991ae25a1ba7a3fec8bf74928a96e8229c37faa8eda2/fonttools-4.58.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c6db489511e867633b859b11aefe1b7c0d90281c5bdb903413edbb2ba77b97f1", size = 2307214, upload-time = "2025-05-10T17:35:38.939Z" }, + { url = "https://files.pythonhosted.org/packages/52/11/1015cedc9878da6d8d1758049749eef857b693e5828d477287a959c8650f/fonttools-4.58.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:107bdb2dacb1f627db3c4b77fb16d065a10fe88978d02b4fc327b9ecf8a62060", size = 4811136, upload-time = "2025-05-10T17:35:41.491Z" }, + { url = "https://files.pythonhosted.org/packages/32/b9/6a1bc1af6ec17eead5d32e87075e22d0dab001eace0b5a1542d38c6a9483/fonttools-4.58.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba7212068ab20f1128a0475f169068ba8e5b6e35a39ba1980b9f53f6ac9720ac", size = 4876598, upload-time = "2025-05-10T17:35:43.986Z" }, + { url = "https://files.pythonhosted.org/packages/d8/46/b14584c7ea65ad1609fb9632251016cda8a2cd66b15606753b9f888d3677/fonttools-4.58.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f95ea3b6a3b9962da3c82db73f46d6a6845a6c3f3f968f5293b3ac1864e771c2", size = 4872256, upload-time = "2025-05-10T17:35:46.617Z" }, + { url = "https://files.pythonhosted.org/packages/05/78/b2105a7812ca4ef9bf180cd741c82f4522316c652ce2a56f788e2eb54b62/fonttools-4.58.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:874f1225cc4ccfeac32009887f722d7f8b107ca5e867dcee067597eef9d4c80b", size = 5028710, upload-time = "2025-05-10T17:35:49.227Z" }, + { url = "https://files.pythonhosted.org/packages/8c/a9/a38c85ffd30d1f2c7a5460c8abfd1aa66e00c198df3ff0b08117f5c6fcd9/fonttools-4.58.0-cp312-cp312-win32.whl", hash = "sha256:5f3cde64ec99c43260e2e6c4fa70dfb0a5e2c1c1d27a4f4fe4618c16f6c9ff71", size = 2173593, upload-time = "2025-05-10T17:35:51.226Z" }, + { url = "https://files.pythonhosted.org/packages/66/48/29752962a74b7ed95da976b5a968bba1fe611a4a7e50b9fefa345e6e7025/fonttools-4.58.0-cp312-cp312-win_amd64.whl", hash = "sha256:2aee08e2818de45067109a207cbd1b3072939f77751ef05904d506111df5d824", size = 2223230, upload-time = "2025-05-10T17:35:53.653Z" }, + { url = "https://files.pythonhosted.org/packages/0c/d7/d77cae11c445916d767cace93ba8283b3f360197d95d7470b90a9e984e10/fonttools-4.58.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:4809790f2371d8a08e59e1ce2b734c954cf09742e75642d7f4c46cfdac488fdd", size = 2728320, upload-time = "2025-05-10T17:35:56.455Z" }, + { url = "https://files.pythonhosted.org/packages/77/48/7d8b3c519ef4b48081d40310262224a38785e39a8610ccb92a229a6f085d/fonttools-4.58.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b00f240280f204ce4546b05ff3515bf8ff47a9cae914c718490025ea2bb9b324", size = 2302570, upload-time = "2025-05-10T17:35:58.794Z" }, + { url = "https://files.pythonhosted.org/packages/2c/48/156b83eb8fb7261056e448bfda1b495b90e761b28ec23cee10e3e19f1967/fonttools-4.58.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5a62015ad463e1925544e9159dd6eefe33ebfb80938d5ab15d8b1c4b354ff47b", size = 4790066, upload-time = "2025-05-10T17:36:01.174Z" }, + { url = "https://files.pythonhosted.org/packages/60/49/aaecb1b3cea2b9b9c7cea6240d6bc8090feb5489a6fbf93cb68003be979b/fonttools-4.58.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ceef6f6ab58061a811967e3e32e630747fcb823dcc33a9a2c80e2d0d17cb292", size = 4861076, upload-time = "2025-05-10T17:36:03.663Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c8/97cbb41bee81ea9daf6109e0f3f70a274a3c69418e5ac6b0193f5dacf506/fonttools-4.58.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c7be21ac52370b515cdbdd0f400803fd29432a4fa4ddb4244ac8b322e54f36c0", size = 4858394, upload-time = "2025-05-10T17:36:06.087Z" }, + { url = "https://files.pythonhosted.org/packages/4d/23/c2c231457361f869a7d7374a557208e303b469d48a4a697c0fb249733ea1/fonttools-4.58.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:85836be4c3c4aacf6fcb7a6f263896d0e9ce431da9fa6fe9213d70f221f131c9", size = 5002160, upload-time = "2025-05-10T17:36:08.178Z" }, + { url = "https://files.pythonhosted.org/packages/a9/e0/c2262f941a43b810c5c192db94b5d1ce8eda91bec2757f7e2416398f4072/fonttools-4.58.0-cp313-cp313-win32.whl", hash = "sha256:2b32b7130277bd742cb8c4379a6a303963597d22adea77a940343f3eadbcaa4c", size = 2171919, upload-time = "2025-05-10T17:36:10.644Z" }, + { url = "https://files.pythonhosted.org/packages/8f/ee/e4aa7bb4ce510ad57a808d321df1bbed1eeb6e1dfb20aaee1a5d9c076849/fonttools-4.58.0-cp313-cp313-win_amd64.whl", hash = "sha256:75e68ee2ec9aaa173cf5e33f243da1d51d653d5e25090f2722bc644a78db0f1a", size = 2222972, upload-time = "2025-05-10T17:36:12.495Z" }, + { url = "https://files.pythonhosted.org/packages/9b/1f/4417c26e26a1feab85a27e927f7a73d8aabc84544be8ba108ce4aa90eb1e/fonttools-4.58.0-py3-none-any.whl", hash = "sha256:c96c36880be2268be409df7b08c5b5dacac1827083461a6bc2cb07b8cbcec1d7", size = 1111440, upload-time = "2025-05-10T17:36:33.607Z" }, +] + +[[package]] +name = "fsspec" +version = "2025.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/00/f7/27f15d41f0ed38e8fcc488584b57e902b331da7f7c6dcda53721b15838fc/fsspec-2025.5.1.tar.gz", hash = "sha256:2e55e47a540b91843b755e83ded97c6e897fa0942b11490113f09e9c443c2475", size = 303033, upload-time = "2025-05-24T12:03:23.792Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bb/61/78c7b3851add1481b048b5fdc29067397a1784e2910592bc81bb3f608635/fsspec-2025.5.1-py3-none-any.whl", hash = "sha256:24d3a2e663d5fc735ab256263c4075f374a174c3410c0b25e5bd1970bceaa462", size = 199052, upload-time = "2025-05-24T12:03:21.66Z" }, +] + +[[package]] +name = "fvcore" +version = "0.1.5.post20221221" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "iopath" }, + { name = "numpy" }, + { name = "pillow" }, + { name = "pyyaml" }, + { name = "tabulate" }, + { name = "termcolor" }, + { name = "tqdm" }, + { name = "yacs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a5/93/d056a9c4efc6c79ba7b5159cc66bb436db93d2cc46dca18ed65c59cc8e4e/fvcore-0.1.5.post20221221.tar.gz", hash = "sha256:f2fb0bb90572ae651c11c78e20493ed19b2240550a7e4bbb2d6de87bdd037860", size = 50217, upload-time = "2022-12-21T08:10:53.563Z" } + +[[package]] +name = "ghp-import" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/29/d40217cbe2f6b1359e00c6c307bb3fc876ba74068cbab3dde77f03ca0dc4/ghp-import-2.1.0.tar.gz", hash = "sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343", size = 10943, upload-time = "2022-05-02T15:47:16.11Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619", size = 11034, upload-time = "2022-05-02T15:47:14.552Z" }, +] + +[[package]] +name = "gradio" +version = "5.31.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiofiles" }, + { name = "anyio" }, + { name = "audioop-lts", marker = "python_full_version >= '3.13'" }, + { name = "fastapi" }, + { name = "ffmpy" }, + { name = "gradio-client" }, + { name = "groovy" }, + { name = "httpx" }, + { name = "huggingface-hub" }, + { name = "jinja2" }, + { name = "markupsafe" }, + { name = "numpy" }, + { name = "orjson" }, + { name = "packaging" }, + { name = "pandas" }, + { name = "pillow" }, + { name = "pydantic" }, + { name = "pydub" }, + { name = "python-multipart" }, + { name = "pyyaml" }, + { name = "ruff", marker = "sys_platform != 'emscripten'" }, + { name = "safehttpx" }, + { name = "semantic-version" }, + { name = "starlette", marker = "sys_platform != 'emscripten'" }, + { name = "tomlkit" }, + { name = "typer", marker = "sys_platform != 'emscripten'" }, + { name = "typing-extensions" }, + { name = "urllib3", marker = "sys_platform == 'emscripten'" }, + { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/43/cd6bd76630472671762a91165ddb29edd1d5f8ddf0ef2942eafbbdb5246b/gradio-5.31.0.tar.gz", hash = "sha256:fba0c84d69cf489938eb64bef4c19d53300eb9af62e8cc0b12e02ae9072b7623", size = 64778139, upload-time = "2025-05-22T19:44:53.367Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/86/a5/630f31e90e1a39c8ec3d2a2404e3d4d6a27c9f15faca61cdd54458700757/gradio-5.31.0-py3-none-any.whl", hash = "sha256:4a31956c7290ce1277db07f15f197b602df2e7be147083136265d52e7887b156", size = 54197094, upload-time = "2025-05-22T19:44:47.607Z" }, +] + +[[package]] +name = "gradio-client" +version = "1.10.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "fsspec" }, + { name = "httpx" }, + { name = "huggingface-hub" }, + { name = "packaging" }, + { name = "typing-extensions" }, + { name = "websockets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/5e/f0e513041613aacc916f7d19eb98f6d209adf278921fd967750b0803afb8/gradio_client-1.10.1.tar.gz", hash = "sha256:550662eae8dc0d06d44cb8d42be74f214db1e793ad4d789d7b7ecb42e82ca045", size = 321147, upload-time = "2025-05-14T21:05:54.911Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/55/6f/03eb8e0e0ec80eced5ed35a63376dabfc7391b1538502f8e85e9dc5bab02/gradio_client-1.10.1-py3-none-any.whl", hash = "sha256:fcff53f6aad3dfa9dd082adedb94256172d6b20666b1ef66480d82023e1907db", size = 323141, upload-time = "2025-05-14T21:05:53.411Z" }, +] + +[[package]] +name = "griffe" +version = "1.7.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/3e/5aa9a61f7c3c47b0b52a1d930302992229d191bf4bc76447b324b731510a/griffe-1.7.3.tar.gz", hash = "sha256:52ee893c6a3a968b639ace8015bec9d36594961e156e23315c8e8e51401fa50b", size = 395137, upload-time = "2025-04-23T11:29:09.147Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/c6/5c20af38c2a57c15d87f7f38bee77d63c1d2a3689f74fefaf35915dd12b2/griffe-1.7.3-py3-none-any.whl", hash = "sha256:c6b3ee30c2f0f17f30bcdef5068d6ab7a2a4f1b8bf1a3e74b56fffd21e1c5f75", size = 129303, upload-time = "2025-04-23T11:29:07.145Z" }, +] + +[[package]] +name = "groovy" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/36/bbdede67400277bef33d3ec0e6a31750da972c469f75966b4930c753218f/groovy-0.1.2.tar.gz", hash = "sha256:25c1dc09b3f9d7e292458aa762c6beb96ea037071bf5e917fc81fb78d2231083", size = 17325, upload-time = "2025-02-28T20:24:56.068Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/28/27/3d6dcadc8a3214d8522c1e7f6a19554e33659be44546d44a2f7572ac7d2a/groovy-0.1.2-py3-none-any.whl", hash = "sha256:7f7975bab18c729a257a8b1ae9dcd70b7cafb1720481beae47719af57c35fa64", size = 14090, upload-time = "2025-02-28T20:24:55.152Z" }, +] + +[[package]] +name = "grpcio" +version = "1.71.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/95/aa11fc09a85d91fbc7dd405dcb2a1e0256989d67bf89fa65ae24b3ba105a/grpcio-1.71.0.tar.gz", hash = "sha256:2b85f7820475ad3edec209d3d89a7909ada16caab05d3f2e08a7e8ae3200a55c", size = 12549828, upload-time = "2025-03-10T19:28:49.203Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/c5/ef610b3f988cc0cc67b765f72b8e2db06a1db14e65acb5ae7810a6b7042e/grpcio-1.71.0-cp310-cp310-linux_armv7l.whl", hash = "sha256:c200cb6f2393468142eb50ab19613229dcc7829b5ccee8b658a36005f6669fdd", size = 5210643, upload-time = "2025-03-10T19:24:11.278Z" }, + { url = "https://files.pythonhosted.org/packages/bf/de/c84293c961622df302c0d5d07ec6e2d4cd3874ea42f602be2df09c4ad44f/grpcio-1.71.0-cp310-cp310-macosx_12_0_universal2.whl", hash = "sha256:b2266862c5ad664a380fbbcdbdb8289d71464c42a8c29053820ee78ba0119e5d", size = 11308962, upload-time = "2025-03-10T19:24:14.766Z" }, + { url = "https://files.pythonhosted.org/packages/7c/38/04c9e0dc8c904570c80faa1f1349b190b63e45d6b2782ec8567b050efa9d/grpcio-1.71.0-cp310-cp310-manylinux_2_17_aarch64.whl", hash = "sha256:0ab8b2864396663a5b0b0d6d79495657ae85fa37dcb6498a2669d067c65c11ea", size = 5699236, upload-time = "2025-03-10T19:24:17.214Z" }, + { url = "https://files.pythonhosted.org/packages/95/96/e7be331d1298fa605ea7c9ceafc931490edd3d5b33c4f695f1a0667f3491/grpcio-1.71.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c30f393f9d5ff00a71bb56de4aa75b8fe91b161aeb61d39528db6b768d7eac69", size = 6339767, upload-time = "2025-03-10T19:24:18.977Z" }, + { url = "https://files.pythonhosted.org/packages/5d/b7/7e7b7bb6bb18baf156fd4f2f5b254150dcdd6cbf0def1ee427a2fb2bfc4d/grpcio-1.71.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f250ff44843d9a0615e350c77f890082102a0318d66a99540f54769c8766ab73", size = 5943028, upload-time = "2025-03-10T19:24:21.746Z" }, + { url = "https://files.pythonhosted.org/packages/13/aa/5fb756175995aeb47238d706530772d9a7ac8e73bcca1b47dc145d02c95f/grpcio-1.71.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e6d8de076528f7c43a2f576bc311799f89d795aa6c9b637377cc2b1616473804", size = 6031841, upload-time = "2025-03-10T19:24:23.912Z" }, + { url = "https://files.pythonhosted.org/packages/54/93/172783e01eed61f7f180617b7fa4470f504e383e32af2587f664576a7101/grpcio-1.71.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:9b91879d6da1605811ebc60d21ab6a7e4bae6c35f6b63a061d61eb818c8168f6", size = 6651039, upload-time = "2025-03-10T19:24:26.075Z" }, + { url = "https://files.pythonhosted.org/packages/6f/99/62654b220a27ed46d3313252214f4bc66261143dc9b58004085cd0646753/grpcio-1.71.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:f71574afdf944e6652203cd1badcda195b2a27d9c83e6d88dc1ce3cfb73b31a5", size = 6198465, upload-time = "2025-03-10T19:24:27.716Z" }, + { url = "https://files.pythonhosted.org/packages/68/35/96116de833b330abe4412cc94edc68f99ed2fa3e39d8713ff307b3799e81/grpcio-1.71.0-cp310-cp310-win32.whl", hash = "sha256:8997d6785e93308f277884ee6899ba63baafa0dfb4729748200fcc537858a509", size = 3620382, upload-time = "2025-03-10T19:24:29.833Z" }, + { url = "https://files.pythonhosted.org/packages/b7/09/f32ef637e386f3f2c02effac49699229fa560ce9007682d24e9e212d2eb4/grpcio-1.71.0-cp310-cp310-win_amd64.whl", hash = "sha256:7d6ac9481d9d0d129224f6d5934d5832c4b1cddb96b59e7eba8416868909786a", size = 4280302, upload-time = "2025-03-10T19:24:31.569Z" }, + { url = "https://files.pythonhosted.org/packages/63/04/a085f3ad4133426f6da8c1becf0749872a49feb625a407a2e864ded3fb12/grpcio-1.71.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:d6aa986318c36508dc1d5001a3ff169a15b99b9f96ef5e98e13522c506b37eef", size = 5210453, upload-time = "2025-03-10T19:24:33.342Z" }, + { url = "https://files.pythonhosted.org/packages/b4/d5/0bc53ed33ba458de95020970e2c22aa8027b26cc84f98bea7fcad5d695d1/grpcio-1.71.0-cp311-cp311-macosx_10_14_universal2.whl", hash = "sha256:d2c170247315f2d7e5798a22358e982ad6eeb68fa20cf7a820bb74c11f0736e7", size = 11347567, upload-time = "2025-03-10T19:24:35.215Z" }, + { url = "https://files.pythonhosted.org/packages/e3/6d/ce334f7e7a58572335ccd61154d808fe681a4c5e951f8a1ff68f5a6e47ce/grpcio-1.71.0-cp311-cp311-manylinux_2_17_aarch64.whl", hash = "sha256:e6f83a583ed0a5b08c5bc7a3fe860bb3c2eac1f03f1f63e0bc2091325605d2b7", size = 5696067, upload-time = "2025-03-10T19:24:37.988Z" }, + { url = "https://files.pythonhosted.org/packages/05/4a/80befd0b8b1dc2b9ac5337e57473354d81be938f87132e147c4a24a581bd/grpcio-1.71.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4be74ddeeb92cc87190e0e376dbc8fc7736dbb6d3d454f2fa1f5be1dee26b9d7", size = 6348377, upload-time = "2025-03-10T19:24:40.361Z" }, + { url = "https://files.pythonhosted.org/packages/c7/67/cbd63c485051eb78663355d9efd1b896cfb50d4a220581ec2cb9a15cd750/grpcio-1.71.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4dd0dfbe4d5eb1fcfec9490ca13f82b089a309dc3678e2edabc144051270a66e", size = 5940407, upload-time = "2025-03-10T19:24:42.685Z" }, + { url = "https://files.pythonhosted.org/packages/98/4b/7a11aa4326d7faa499f764eaf8a9b5a0eb054ce0988ee7ca34897c2b02ae/grpcio-1.71.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a2242d6950dc892afdf9e951ed7ff89473aaf744b7d5727ad56bdaace363722b", size = 6030915, upload-time = "2025-03-10T19:24:44.463Z" }, + { url = "https://files.pythonhosted.org/packages/eb/a2/cdae2d0e458b475213a011078b0090f7a1d87f9a68c678b76f6af7c6ac8c/grpcio-1.71.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:0fa05ee31a20456b13ae49ad2e5d585265f71dd19fbd9ef983c28f926d45d0a7", size = 6648324, upload-time = "2025-03-10T19:24:46.287Z" }, + { url = "https://files.pythonhosted.org/packages/27/df/f345c8daaa8d8574ce9869f9b36ca220c8845923eb3087e8f317eabfc2a8/grpcio-1.71.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:3d081e859fb1ebe176de33fc3adb26c7d46b8812f906042705346b314bde32c3", size = 6197839, upload-time = "2025-03-10T19:24:48.565Z" }, + { url = "https://files.pythonhosted.org/packages/f2/2c/cd488dc52a1d0ae1bad88b0d203bc302efbb88b82691039a6d85241c5781/grpcio-1.71.0-cp311-cp311-win32.whl", hash = "sha256:d6de81c9c00c8a23047136b11794b3584cdc1460ed7cbc10eada50614baa1444", size = 3619978, upload-time = "2025-03-10T19:24:50.518Z" }, + { url = "https://files.pythonhosted.org/packages/ee/3f/cf92e7e62ccb8dbdf977499547dfc27133124d6467d3a7d23775bcecb0f9/grpcio-1.71.0-cp311-cp311-win_amd64.whl", hash = "sha256:24e867651fc67717b6f896d5f0cac0ec863a8b5fb7d6441c2ab428f52c651c6b", size = 4282279, upload-time = "2025-03-10T19:24:52.313Z" }, + { url = "https://files.pythonhosted.org/packages/4c/83/bd4b6a9ba07825bd19c711d8b25874cd5de72c2a3fbf635c3c344ae65bd2/grpcio-1.71.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:0ff35c8d807c1c7531d3002be03221ff9ae15712b53ab46e2a0b4bb271f38537", size = 5184101, upload-time = "2025-03-10T19:24:54.11Z" }, + { url = "https://files.pythonhosted.org/packages/31/ea/2e0d90c0853568bf714693447f5c73272ea95ee8dad107807fde740e595d/grpcio-1.71.0-cp312-cp312-macosx_10_14_universal2.whl", hash = "sha256:b78a99cd1ece4be92ab7c07765a0b038194ded2e0a26fd654591ee136088d8d7", size = 11310927, upload-time = "2025-03-10T19:24:56.1Z" }, + { url = "https://files.pythonhosted.org/packages/ac/bc/07a3fd8af80467390af491d7dc66882db43884128cdb3cc8524915e0023c/grpcio-1.71.0-cp312-cp312-manylinux_2_17_aarch64.whl", hash = "sha256:dc1a1231ed23caac1de9f943d031f1bc38d0f69d2a3b243ea0d664fc1fbd7fec", size = 5654280, upload-time = "2025-03-10T19:24:58.55Z" }, + { url = "https://files.pythonhosted.org/packages/16/af/21f22ea3eed3d0538b6ef7889fce1878a8ba4164497f9e07385733391e2b/grpcio-1.71.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e6beeea5566092c5e3c4896c6d1d307fb46b1d4bdf3e70c8340b190a69198594", size = 6312051, upload-time = "2025-03-10T19:25:00.682Z" }, + { url = "https://files.pythonhosted.org/packages/49/9d/e12ddc726dc8bd1aa6cba67c85ce42a12ba5b9dd75d5042214a59ccf28ce/grpcio-1.71.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d5170929109450a2c031cfe87d6716f2fae39695ad5335d9106ae88cc32dc84c", size = 5910666, upload-time = "2025-03-10T19:25:03.01Z" }, + { url = "https://files.pythonhosted.org/packages/d9/e9/38713d6d67aedef738b815763c25f092e0454dc58e77b1d2a51c9d5b3325/grpcio-1.71.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:5b08d03ace7aca7b2fadd4baf291139b4a5f058805a8327bfe9aece7253b6d67", size = 6012019, upload-time = "2025-03-10T19:25:05.174Z" }, + { url = "https://files.pythonhosted.org/packages/80/da/4813cd7adbae6467724fa46c952d7aeac5e82e550b1c62ed2aeb78d444ae/grpcio-1.71.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:f903017db76bf9cc2b2d8bdd37bf04b505bbccad6be8a81e1542206875d0e9db", size = 6637043, upload-time = "2025-03-10T19:25:06.987Z" }, + { url = "https://files.pythonhosted.org/packages/52/ca/c0d767082e39dccb7985c73ab4cf1d23ce8613387149e9978c70c3bf3b07/grpcio-1.71.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:469f42a0b410883185eab4689060a20488a1a0a00f8bbb3cbc1061197b4c5a79", size = 6186143, upload-time = "2025-03-10T19:25:08.877Z" }, + { url = "https://files.pythonhosted.org/packages/00/61/7b2c8ec13303f8fe36832c13d91ad4d4ba57204b1c723ada709c346b2271/grpcio-1.71.0-cp312-cp312-win32.whl", hash = "sha256:ad9f30838550695b5eb302add33f21f7301b882937460dd24f24b3cc5a95067a", size = 3604083, upload-time = "2025-03-10T19:25:10.736Z" }, + { url = "https://files.pythonhosted.org/packages/fd/7c/1e429c5fb26122055d10ff9a1d754790fb067d83c633ff69eddcf8e3614b/grpcio-1.71.0-cp312-cp312-win_amd64.whl", hash = "sha256:652350609332de6dac4ece254e5d7e1ff834e203d6afb769601f286886f6f3a8", size = 4272191, upload-time = "2025-03-10T19:25:13.12Z" }, + { url = "https://files.pythonhosted.org/packages/04/dd/b00cbb45400d06b26126dcfdbdb34bb6c4f28c3ebbd7aea8228679103ef6/grpcio-1.71.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:cebc1b34ba40a312ab480ccdb396ff3c529377a2fce72c45a741f7215bfe8379", size = 5184138, upload-time = "2025-03-10T19:25:15.101Z" }, + { url = "https://files.pythonhosted.org/packages/ed/0a/4651215983d590ef53aac40ba0e29dda941a02b097892c44fa3357e706e5/grpcio-1.71.0-cp313-cp313-macosx_10_14_universal2.whl", hash = "sha256:85da336e3649a3d2171e82f696b5cad2c6231fdd5bad52616476235681bee5b3", size = 11310747, upload-time = "2025-03-10T19:25:17.201Z" }, + { url = "https://files.pythonhosted.org/packages/57/a3/149615b247f321e13f60aa512d3509d4215173bdb982c9098d78484de216/grpcio-1.71.0-cp313-cp313-manylinux_2_17_aarch64.whl", hash = "sha256:f9a412f55bb6e8f3bb000e020dbc1e709627dcb3a56f6431fa7076b4c1aab0db", size = 5653991, upload-time = "2025-03-10T19:25:20.39Z" }, + { url = "https://files.pythonhosted.org/packages/ca/56/29432a3e8d951b5e4e520a40cd93bebaa824a14033ea8e65b0ece1da6167/grpcio-1.71.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:47be9584729534660416f6d2a3108aaeac1122f6b5bdbf9fd823e11fe6fbaa29", size = 6312781, upload-time = "2025-03-10T19:25:22.823Z" }, + { url = "https://files.pythonhosted.org/packages/a3/f8/286e81a62964ceb6ac10b10925261d4871a762d2a763fbf354115f9afc98/grpcio-1.71.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7c9c80ac6091c916db81131d50926a93ab162a7e97e4428ffc186b6e80d6dda4", size = 5910479, upload-time = "2025-03-10T19:25:24.828Z" }, + { url = "https://files.pythonhosted.org/packages/35/67/d1febb49ec0f599b9e6d4d0d44c2d4afdbed9c3e80deb7587ec788fcf252/grpcio-1.71.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:789d5e2a3a15419374b7b45cd680b1e83bbc1e52b9086e49308e2c0b5bbae6e3", size = 6013262, upload-time = "2025-03-10T19:25:26.987Z" }, + { url = "https://files.pythonhosted.org/packages/a1/04/f9ceda11755f0104a075ad7163fc0d96e2e3a9fe25ef38adfc74c5790daf/grpcio-1.71.0-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:1be857615e26a86d7363e8a163fade914595c81fec962b3d514a4b1e8760467b", size = 6643356, upload-time = "2025-03-10T19:25:29.606Z" }, + { url = "https://files.pythonhosted.org/packages/fb/ce/236dbc3dc77cf9a9242adcf1f62538734ad64727fabf39e1346ad4bd5c75/grpcio-1.71.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:a76d39b5fafd79ed604c4be0a869ec3581a172a707e2a8d7a4858cb05a5a7637", size = 6186564, upload-time = "2025-03-10T19:25:31.537Z" }, + { url = "https://files.pythonhosted.org/packages/10/fd/b3348fce9dd4280e221f513dd54024e765b21c348bc475516672da4218e9/grpcio-1.71.0-cp313-cp313-win32.whl", hash = "sha256:74258dce215cb1995083daa17b379a1a5a87d275387b7ffe137f1d5131e2cfbb", size = 3601890, upload-time = "2025-03-10T19:25:33.421Z" }, + { url = "https://files.pythonhosted.org/packages/be/f8/db5d5f3fc7e296166286c2a397836b8b042f7ad1e11028d82b061701f0f7/grpcio-1.71.0-cp313-cp313-win_amd64.whl", hash = "sha256:22c3bc8d488c039a199f7a003a38cb7635db6656fa96437a8accde8322ce2366", size = 4273308, upload-time = "2025-03-10T19:25:35.79Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "hf-xet" +version = "1.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/95/be/58f20728a5b445f8b064e74f0618897b3439f5ef90934da1916b9dfac76f/hf_xet-1.1.2.tar.gz", hash = "sha256:3712d6d4819d3976a1c18e36db9f503e296283f9363af818f50703506ed63da3", size = 467009, upload-time = "2025-05-16T20:44:34.944Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/ae/f1a63f75d9886f18a80220ba31a1c7b9c4752f03aae452f358f538c6a991/hf_xet-1.1.2-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:dfd1873fd648488c70735cb60f7728512bca0e459e61fcd107069143cd798469", size = 2642559, upload-time = "2025-05-16T20:44:30.217Z" }, + { url = "https://files.pythonhosted.org/packages/50/ab/d2c83ae18f1015d926defd5bfbe94c62d15e93f900e6a192e318ee947105/hf_xet-1.1.2-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:29b584983b2d977c44157d9241dcf0fd50acde0b7bff8897fe4386912330090d", size = 2541360, upload-time = "2025-05-16T20:44:29.056Z" }, + { url = "https://files.pythonhosted.org/packages/9f/a7/693dc9f34f979e30a378125e2150a0b2d8d166e6d83ce3950eeb81e560aa/hf_xet-1.1.2-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6b29ac84298147fe9164cc55ad994ba47399f90b5d045b0b803b99cf5f06d8ec", size = 5183081, upload-time = "2025-05-16T20:44:27.505Z" }, + { url = "https://files.pythonhosted.org/packages/3d/23/c48607883f692a36c0a7735f47f98bad32dbe459a32d1568c0f21576985d/hf_xet-1.1.2-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d921ba32615676e436a0d15e162331abc9ed43d440916b1d836dc27ce1546173", size = 5356100, upload-time = "2025-05-16T20:44:25.681Z" }, + { url = "https://files.pythonhosted.org/packages/eb/5b/b2316c7f1076da0582b52ea228f68bea95e243c388440d1dc80297c9d813/hf_xet-1.1.2-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:d9b03c34e13c44893ab6e8fea18ee8d2a6878c15328dd3aabedbdd83ee9f2ed3", size = 5647688, upload-time = "2025-05-16T20:44:31.867Z" }, + { url = "https://files.pythonhosted.org/packages/2c/98/e6995f0fa579929da7795c961f403f4ee84af36c625963f52741d56f242c/hf_xet-1.1.2-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:01b18608955b3d826307d37da8bd38b28a46cd2d9908b3a3655d1363274f941a", size = 5322627, upload-time = "2025-05-16T20:44:33.677Z" }, + { url = "https://files.pythonhosted.org/packages/59/40/8f1d5a44a64d8bf9e3c19576e789f716af54875b46daae65426714e75db1/hf_xet-1.1.2-cp37-abi3-win_amd64.whl", hash = "sha256:3562902c81299b09f3582ddfb324400c6a901a2f3bc854f83556495755f4954c", size = 2739542, upload-time = "2025-05-16T20:44:36.287Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "huggingface-hub" +version = "0.32.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock" }, + { name = "fsspec" }, + { name = "hf-xet", marker = "platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64'" }, + { name = "packaging" }, + { name = "pyyaml" }, + { name = "requests" }, + { name = "tqdm" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d0/76/44f7025d1b3f29336aeb7324a57dd7c19f7c69f6612b7637b39ac7c17302/huggingface_hub-0.32.2.tar.gz", hash = "sha256:64a288b1eadad6b60bbfd50f0e52fd6cfa2ef77ab13c3e8a834a038ae929de54", size = 422847, upload-time = "2025-05-27T09:23:00.306Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/30/532fe57467a6cc7ff2e39f088db1cb6d6bf522f724a4a5c7beda1282d5a6/huggingface_hub-0.32.2-py3-none-any.whl", hash = "sha256:f8fcf14603237eadf96dbe577d30b330f8c27b4a0a31e8f6c94fdc25e021fdb8", size = 509968, upload-time = "2025-05-27T09:22:57.967Z" }, +] + +[[package]] +name = "humanfriendly" +version = "10.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyreadline3", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cc/3f/2c29224acb2e2df4d2046e4c73ee2662023c58ff5b113c4c1adac0886c43/humanfriendly-10.0.tar.gz", hash = "sha256:6b0b831ce8f15f7300721aa49829fc4e83921a9a301cc7f606be6686a2288ddc", size = 360702, upload-time = "2021-09-17T21:40:43.31Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f0/0f/310fb31e39e2d734ccaa2c0fb981ee41f7bd5056ce9bc29b2248bd569169/humanfriendly-10.0-py2.py3-none-any.whl", hash = "sha256:1697e1a8a8f550fd43c2865cd84542fc175a61dcb779b6fee18cf6b6ccba1477", size = 86794, upload-time = "2021-09-17T21:40:39.897Z" }, +] + +[[package]] +name = "identify" +version = "2.6.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/88/d193a27416618628a5eea64e3223acd800b40749a96ffb322a9b55a49ed1/identify-2.6.12.tar.gz", hash = "sha256:d8de45749f1efb108badef65ee8386f0f7bb19a7f26185f74de6367bffbaf0e6", size = 99254, upload-time = "2025-05-23T20:37:53.3Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/cd/18f8da995b658420625f7ef13f037be53ae04ec5ad33f9b718240dcfd48c/identify-2.6.12-py2.py3-none-any.whl", hash = "sha256:ad9672d5a72e0d2ff7c5c8809b62dfa60458626352fb0eb7b55e69bdc45334a2", size = 99145, upload-time = "2025-05-23T20:37:51.495Z" }, +] + +[[package]] +name = "idna" +version = "3.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" }, +] + +[[package]] +name = "iopath" +version = "0.1.10" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "portalocker" }, + { name = "tqdm" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/73/b3d451dfc523756cf177d3ebb0af76dc7751b341c60e2a21871be400ae29/iopath-0.1.10.tar.gz", hash = "sha256:3311c16a4d9137223e20f141655759933e1eda24f8bff166af834af3c645ef01", size = 42226, upload-time = "2022-07-09T19:00:50.866Z" } + +[[package]] +name = "ipykernel" +version = "6.29.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "appnope", marker = "sys_platform == 'darwin'" }, + { name = "comm" }, + { name = "debugpy" }, + { name = "ipython" }, + { name = "jupyter-client" }, + { name = "jupyter-core" }, + { name = "matplotlib-inline" }, + { name = "nest-asyncio" }, + { name = "packaging" }, + { name = "psutil" }, + { name = "pyzmq" }, + { name = "tornado" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/5c/67594cb0c7055dc50814b21731c22a601101ea3b1b50a9a1b090e11f5d0f/ipykernel-6.29.5.tar.gz", hash = "sha256:f093a22c4a40f8828f8e330a9c297cb93dcab13bd9678ded6de8e5cf81c56215", size = 163367, upload-time = "2024-07-01T14:07:22.543Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/5c/368ae6c01c7628438358e6d337c19b05425727fbb221d2a3c4303c372f42/ipykernel-6.29.5-py3-none-any.whl", hash = "sha256:afdb66ba5aa354b09b91379bac28ae4afebbb30e8b39510c9690afb7a10421b5", size = 117173, upload-time = "2024-07-01T14:07:19.603Z" }, +] + +[[package]] +name = "ipython" +version = "8.36.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "decorator" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "jedi" }, + { name = "matplotlib-inline" }, + { name = "pexpect", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "prompt-toolkit" }, + { name = "pygments" }, + { name = "stack-data" }, + { name = "traitlets" }, + { name = "typing-extensions", marker = "python_full_version < '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a2/9f/d9a73710df947b7804bd9d93509463fb3a89e0ddc99c9fcc67279cddbeb6/ipython-8.36.0.tar.gz", hash = "sha256:24658e9fe5c5c819455043235ba59cfffded4a35936eefceceab6b192f7092ff", size = 5604997, upload-time = "2025-04-25T18:03:38.031Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d6/d7/c1c9f371790b3a181e343c4815a361e5a0cc7d90ef6642d64ba5d05de289/ipython-8.36.0-py3-none-any.whl", hash = "sha256:12b913914d010dcffa2711505ec8be4bf0180742d97f1e5175e51f22086428c1", size = 831074, upload-time = "2025-04-25T18:03:34.951Z" }, +] + +[[package]] +name = "jedi" +version = "0.19.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "parso" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/3a/79a912fbd4d8dd6fbb02bf69afd3bb72cf0c729bb3063c6f4498603db17a/jedi-0.19.2.tar.gz", hash = "sha256:4770dc3de41bde3966b02eb84fbcf557fb33cce26ad23da12c742fb50ecb11f0", size = 1231287, upload-time = "2024-11-11T01:41:42.873Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/5a/9cac0c82afec3d09ccd97c8b6502d48f165f9124db81b4bcb90b4af974ee/jedi-0.19.2-py2.py3-none-any.whl", hash = "sha256:a8ef22bde8490f57fe5c7681a3c83cb58874daf72b4784de3cce5b6ef6edb5b9", size = 1572278, upload-time = "2024-11-11T01:41:40.175Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "jupyter-client" +version = "8.6.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jupyter-core" }, + { name = "python-dateutil" }, + { name = "pyzmq" }, + { name = "tornado" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/22/bf9f12fdaeae18019a468b68952a60fe6dbab5d67cd2a103cac7659b41ca/jupyter_client-8.6.3.tar.gz", hash = "sha256:35b3a0947c4a6e9d589eb97d7d4cd5e90f910ee73101611f01283732bd6d9419", size = 342019, upload-time = "2024-09-17T10:44:17.613Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/11/85/b0394e0b6fcccd2c1eeefc230978a6f8cb0c5df1e4cd3e7625735a0d7d1e/jupyter_client-8.6.3-py3-none-any.whl", hash = "sha256:e8a19cc986cc45905ac3362915f410f3af85424b4c0905e94fa5f2cb08e8f23f", size = 106105, upload-time = "2024-09-17T10:44:15.218Z" }, +] + +[[package]] +name = "jupyter-core" +version = "5.8.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "platformdirs" }, + { name = "pywin32", marker = "platform_python_implementation != 'PyPy' and sys_platform == 'win32'" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/99/1b/72906d554acfeb588332eaaa6f61577705e9ec752ddb486f302dafa292d9/jupyter_core-5.8.1.tar.gz", hash = "sha256:0a5f9706f70e64786b75acba995988915ebd4601c8a52e534a40b51c95f59941", size = 88923, upload-time = "2025-05-27T07:38:16.655Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2f/57/6bffd4b20b88da3800c5d691e0337761576ee688eb01299eae865689d2df/jupyter_core-5.8.1-py3-none-any.whl", hash = "sha256:c28d268fc90fb53f1338ded2eb410704c5449a358406e8a948b75706e24863d0", size = 28880, upload-time = "2025-05-27T07:38:15.137Z" }, +] + +[[package]] +name = "kiwisolver" +version = "1.4.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/59/7c91426a8ac292e1cdd53a63b6d9439abd573c875c3f92c146767dd33faf/kiwisolver-1.4.8.tar.gz", hash = "sha256:23d5f023bdc8c7e54eb65f03ca5d5bb25b601eac4d7f1a042888a1f45237987e", size = 97538, upload-time = "2024-12-24T18:30:51.519Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/5f/4d8e9e852d98ecd26cdf8eaf7ed8bc33174033bba5e07001b289f07308fd/kiwisolver-1.4.8-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:88c6f252f6816a73b1f8c904f7bbe02fd67c09a69f7cb8a0eecdbf5ce78e63db", size = 124623, upload-time = "2024-12-24T18:28:17.687Z" }, + { url = "https://files.pythonhosted.org/packages/1d/70/7f5af2a18a76fe92ea14675f8bd88ce53ee79e37900fa5f1a1d8e0b42998/kiwisolver-1.4.8-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c72941acb7b67138f35b879bbe85be0f6c6a70cab78fe3ef6db9c024d9223e5b", size = 66720, upload-time = "2024-12-24T18:28:19.158Z" }, + { url = "https://files.pythonhosted.org/packages/c6/13/e15f804a142353aefd089fadc8f1d985561a15358c97aca27b0979cb0785/kiwisolver-1.4.8-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ce2cf1e5688edcb727fdf7cd1bbd0b6416758996826a8be1d958f91880d0809d", size = 65413, upload-time = "2024-12-24T18:28:20.064Z" }, + { url = "https://files.pythonhosted.org/packages/ce/6d/67d36c4d2054e83fb875c6b59d0809d5c530de8148846b1370475eeeece9/kiwisolver-1.4.8-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:c8bf637892dc6e6aad2bc6d4d69d08764166e5e3f69d469e55427b6ac001b19d", size = 1650826, upload-time = "2024-12-24T18:28:21.203Z" }, + { url = "https://files.pythonhosted.org/packages/de/c6/7b9bb8044e150d4d1558423a1568e4f227193662a02231064e3824f37e0a/kiwisolver-1.4.8-cp310-cp310-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:034d2c891f76bd3edbdb3ea11140d8510dca675443da7304205a2eaa45d8334c", size = 1628231, upload-time = "2024-12-24T18:28:23.851Z" }, + { url = "https://files.pythonhosted.org/packages/b6/38/ad10d437563063eaaedbe2c3540a71101fc7fb07a7e71f855e93ea4de605/kiwisolver-1.4.8-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d47b28d1dfe0793d5e96bce90835e17edf9a499b53969b03c6c47ea5985844c3", size = 1408938, upload-time = "2024-12-24T18:28:26.687Z" }, + { url = "https://files.pythonhosted.org/packages/52/ce/c0106b3bd7f9e665c5f5bc1e07cc95b5dabd4e08e3dad42dbe2faad467e7/kiwisolver-1.4.8-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:eb158fe28ca0c29f2260cca8c43005329ad58452c36f0edf298204de32a9a3ed", size = 1422799, upload-time = "2024-12-24T18:28:30.538Z" }, + { url = "https://files.pythonhosted.org/packages/d0/87/efb704b1d75dc9758087ba374c0f23d3254505edaedd09cf9d247f7878b9/kiwisolver-1.4.8-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d5536185fce131780ebd809f8e623bf4030ce1b161353166c49a3c74c287897f", size = 1354362, upload-time = "2024-12-24T18:28:32.943Z" }, + { url = "https://files.pythonhosted.org/packages/eb/b3/fd760dc214ec9a8f208b99e42e8f0130ff4b384eca8b29dd0efc62052176/kiwisolver-1.4.8-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:369b75d40abedc1da2c1f4de13f3482cb99e3237b38726710f4a793432b1c5ff", size = 2222695, upload-time = "2024-12-24T18:28:35.641Z" }, + { url = "https://files.pythonhosted.org/packages/a2/09/a27fb36cca3fc01700687cc45dae7a6a5f8eeb5f657b9f710f788748e10d/kiwisolver-1.4.8-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:641f2ddf9358c80faa22e22eb4c9f54bd3f0e442e038728f500e3b978d00aa7d", size = 2370802, upload-time = "2024-12-24T18:28:38.357Z" }, + { url = "https://files.pythonhosted.org/packages/3d/c3/ba0a0346db35fe4dc1f2f2cf8b99362fbb922d7562e5f911f7ce7a7b60fa/kiwisolver-1.4.8-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:d561d2d8883e0819445cfe58d7ddd673e4015c3c57261d7bdcd3710d0d14005c", size = 2334646, upload-time = "2024-12-24T18:28:40.941Z" }, + { url = "https://files.pythonhosted.org/packages/41/52/942cf69e562f5ed253ac67d5c92a693745f0bed3c81f49fc0cbebe4d6b00/kiwisolver-1.4.8-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:1732e065704b47c9afca7ffa272f845300a4eb959276bf6970dc07265e73b605", size = 2467260, upload-time = "2024-12-24T18:28:42.273Z" }, + { url = "https://files.pythonhosted.org/packages/32/26/2d9668f30d8a494b0411d4d7d4ea1345ba12deb6a75274d58dd6ea01e951/kiwisolver-1.4.8-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:bcb1ebc3547619c3b58a39e2448af089ea2ef44b37988caf432447374941574e", size = 2288633, upload-time = "2024-12-24T18:28:44.87Z" }, + { url = "https://files.pythonhosted.org/packages/98/99/0dd05071654aa44fe5d5e350729961e7bb535372935a45ac89a8924316e6/kiwisolver-1.4.8-cp310-cp310-win_amd64.whl", hash = "sha256:89c107041f7b27844179ea9c85d6da275aa55ecf28413e87624d033cf1f6b751", size = 71885, upload-time = "2024-12-24T18:28:47.346Z" }, + { url = "https://files.pythonhosted.org/packages/6c/fc/822e532262a97442989335394d441cd1d0448c2e46d26d3e04efca84df22/kiwisolver-1.4.8-cp310-cp310-win_arm64.whl", hash = "sha256:b5773efa2be9eb9fcf5415ea3ab70fc785d598729fd6057bea38d539ead28271", size = 65175, upload-time = "2024-12-24T18:28:49.651Z" }, + { url = "https://files.pythonhosted.org/packages/da/ed/c913ee28936c371418cb167b128066ffb20bbf37771eecc2c97edf8a6e4c/kiwisolver-1.4.8-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:a4d3601908c560bdf880f07d94f31d734afd1bb71e96585cace0e38ef44c6d84", size = 124635, upload-time = "2024-12-24T18:28:51.826Z" }, + { url = "https://files.pythonhosted.org/packages/4c/45/4a7f896f7467aaf5f56ef093d1f329346f3b594e77c6a3c327b2d415f521/kiwisolver-1.4.8-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:856b269c4d28a5c0d5e6c1955ec36ebfd1651ac00e1ce0afa3e28da95293b561", size = 66717, upload-time = "2024-12-24T18:28:54.256Z" }, + { url = "https://files.pythonhosted.org/packages/5f/b4/c12b3ac0852a3a68f94598d4c8d569f55361beef6159dce4e7b624160da2/kiwisolver-1.4.8-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c2b9a96e0f326205af81a15718a9073328df1173a2619a68553decb7097fd5d7", size = 65413, upload-time = "2024-12-24T18:28:55.184Z" }, + { url = "https://files.pythonhosted.org/packages/a9/98/1df4089b1ed23d83d410adfdc5947245c753bddfbe06541c4aae330e9e70/kiwisolver-1.4.8-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c5020c83e8553f770cb3b5fc13faac40f17e0b205bd237aebd21d53d733adb03", size = 1343994, upload-time = "2024-12-24T18:28:57.493Z" }, + { url = "https://files.pythonhosted.org/packages/8d/bf/b4b169b050c8421a7c53ea1ea74e4ef9c335ee9013216c558a047f162d20/kiwisolver-1.4.8-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dace81d28c787956bfbfbbfd72fdcef014f37d9b48830829e488fdb32b49d954", size = 1434804, upload-time = "2024-12-24T18:29:00.077Z" }, + { url = "https://files.pythonhosted.org/packages/66/5a/e13bd341fbcf73325ea60fdc8af752addf75c5079867af2e04cc41f34434/kiwisolver-1.4.8-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:11e1022b524bd48ae56c9b4f9296bce77e15a2e42a502cceba602f804b32bb79", size = 1450690, upload-time = "2024-12-24T18:29:01.401Z" }, + { url = "https://files.pythonhosted.org/packages/9b/4f/5955dcb376ba4a830384cc6fab7d7547bd6759fe75a09564910e9e3bb8ea/kiwisolver-1.4.8-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3b9b4d2892fefc886f30301cdd80debd8bb01ecdf165a449eb6e78f79f0fabd6", size = 1376839, upload-time = "2024-12-24T18:29:02.685Z" }, + { url = "https://files.pythonhosted.org/packages/3a/97/5edbed69a9d0caa2e4aa616ae7df8127e10f6586940aa683a496c2c280b9/kiwisolver-1.4.8-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3a96c0e790ee875d65e340ab383700e2b4891677b7fcd30a699146f9384a2bb0", size = 1435109, upload-time = "2024-12-24T18:29:04.113Z" }, + { url = "https://files.pythonhosted.org/packages/13/fc/e756382cb64e556af6c1809a1bbb22c141bbc2445049f2da06b420fe52bf/kiwisolver-1.4.8-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:23454ff084b07ac54ca8be535f4174170c1094a4cff78fbae4f73a4bcc0d4dab", size = 2245269, upload-time = "2024-12-24T18:29:05.488Z" }, + { url = "https://files.pythonhosted.org/packages/76/15/e59e45829d7f41c776d138245cabae6515cb4eb44b418f6d4109c478b481/kiwisolver-1.4.8-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:87b287251ad6488e95b4f0b4a79a6d04d3ea35fde6340eb38fbd1ca9cd35bbbc", size = 2393468, upload-time = "2024-12-24T18:29:06.79Z" }, + { url = "https://files.pythonhosted.org/packages/e9/39/483558c2a913ab8384d6e4b66a932406f87c95a6080112433da5ed668559/kiwisolver-1.4.8-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:b21dbe165081142b1232a240fc6383fd32cdd877ca6cc89eab93e5f5883e1c25", size = 2355394, upload-time = "2024-12-24T18:29:08.24Z" }, + { url = "https://files.pythonhosted.org/packages/01/aa/efad1fbca6570a161d29224f14b082960c7e08268a133fe5dc0f6906820e/kiwisolver-1.4.8-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:768cade2c2df13db52475bd28d3a3fac8c9eff04b0e9e2fda0f3760f20b3f7fc", size = 2490901, upload-time = "2024-12-24T18:29:09.653Z" }, + { url = "https://files.pythonhosted.org/packages/c9/4f/15988966ba46bcd5ab9d0c8296914436720dd67fca689ae1a75b4ec1c72f/kiwisolver-1.4.8-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d47cfb2650f0e103d4bf68b0b5804c68da97272c84bb12850d877a95c056bd67", size = 2312306, upload-time = "2024-12-24T18:29:12.644Z" }, + { url = "https://files.pythonhosted.org/packages/2d/27/bdf1c769c83f74d98cbc34483a972f221440703054894a37d174fba8aa68/kiwisolver-1.4.8-cp311-cp311-win_amd64.whl", hash = "sha256:ed33ca2002a779a2e20eeb06aea7721b6e47f2d4b8a8ece979d8ba9e2a167e34", size = 71966, upload-time = "2024-12-24T18:29:14.089Z" }, + { url = "https://files.pythonhosted.org/packages/4a/c9/9642ea855604aeb2968a8e145fc662edf61db7632ad2e4fb92424be6b6c0/kiwisolver-1.4.8-cp311-cp311-win_arm64.whl", hash = "sha256:16523b40aab60426ffdebe33ac374457cf62863e330a90a0383639ce14bf44b2", size = 65311, upload-time = "2024-12-24T18:29:15.892Z" }, + { url = "https://files.pythonhosted.org/packages/fc/aa/cea685c4ab647f349c3bc92d2daf7ae34c8e8cf405a6dcd3a497f58a2ac3/kiwisolver-1.4.8-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d6af5e8815fd02997cb6ad9bbed0ee1e60014438ee1a5c2444c96f87b8843502", size = 124152, upload-time = "2024-12-24T18:29:16.85Z" }, + { url = "https://files.pythonhosted.org/packages/c5/0b/8db6d2e2452d60d5ebc4ce4b204feeb16176a851fd42462f66ade6808084/kiwisolver-1.4.8-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bade438f86e21d91e0cf5dd7c0ed00cda0f77c8c1616bd83f9fc157fa6760d31", size = 66555, upload-time = "2024-12-24T18:29:19.146Z" }, + { url = "https://files.pythonhosted.org/packages/60/26/d6a0db6785dd35d3ba5bf2b2df0aedc5af089962c6eb2cbf67a15b81369e/kiwisolver-1.4.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b83dc6769ddbc57613280118fb4ce3cd08899cc3369f7d0e0fab518a7cf37fdb", size = 65067, upload-time = "2024-12-24T18:29:20.096Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ed/1d97f7e3561e09757a196231edccc1bcf59d55ddccefa2afc9c615abd8e0/kiwisolver-1.4.8-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:111793b232842991be367ed828076b03d96202c19221b5ebab421ce8bcad016f", size = 1378443, upload-time = "2024-12-24T18:29:22.843Z" }, + { url = "https://files.pythonhosted.org/packages/29/61/39d30b99954e6b46f760e6289c12fede2ab96a254c443639052d1b573fbc/kiwisolver-1.4.8-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:257af1622860e51b1a9d0ce387bf5c2c4f36a90594cb9514f55b074bcc787cfc", size = 1472728, upload-time = "2024-12-24T18:29:24.463Z" }, + { url = "https://files.pythonhosted.org/packages/0c/3e/804163b932f7603ef256e4a715e5843a9600802bb23a68b4e08c8c0ff61d/kiwisolver-1.4.8-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:69b5637c3f316cab1ec1c9a12b8c5f4750a4c4b71af9157645bf32830e39c03a", size = 1478388, upload-time = "2024-12-24T18:29:25.776Z" }, + { url = "https://files.pythonhosted.org/packages/8a/9e/60eaa75169a154700be74f875a4d9961b11ba048bef315fbe89cb6999056/kiwisolver-1.4.8-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:782bb86f245ec18009890e7cb8d13a5ef54dcf2ebe18ed65f795e635a96a1c6a", size = 1413849, upload-time = "2024-12-24T18:29:27.202Z" }, + { url = "https://files.pythonhosted.org/packages/bc/b3/9458adb9472e61a998c8c4d95cfdfec91c73c53a375b30b1428310f923e4/kiwisolver-1.4.8-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc978a80a0db3a66d25767b03688f1147a69e6237175c0f4ffffaaedf744055a", size = 1475533, upload-time = "2024-12-24T18:29:28.638Z" }, + { url = "https://files.pythonhosted.org/packages/e4/7a/0a42d9571e35798de80aef4bb43a9b672aa7f8e58643d7bd1950398ffb0a/kiwisolver-1.4.8-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:36dbbfd34838500a31f52c9786990d00150860e46cd5041386f217101350f0d3", size = 2268898, upload-time = "2024-12-24T18:29:30.368Z" }, + { url = "https://files.pythonhosted.org/packages/d9/07/1255dc8d80271400126ed8db35a1795b1a2c098ac3a72645075d06fe5c5d/kiwisolver-1.4.8-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:eaa973f1e05131de5ff3569bbba7f5fd07ea0595d3870ed4a526d486fe57fa1b", size = 2425605, upload-time = "2024-12-24T18:29:33.151Z" }, + { url = "https://files.pythonhosted.org/packages/84/df/5a3b4cf13780ef6f6942df67b138b03b7e79e9f1f08f57c49957d5867f6e/kiwisolver-1.4.8-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a66f60f8d0c87ab7f59b6fb80e642ebb29fec354a4dfad687ca4092ae69d04f4", size = 2375801, upload-time = "2024-12-24T18:29:34.584Z" }, + { url = "https://files.pythonhosted.org/packages/8f/10/2348d068e8b0f635c8c86892788dac7a6b5c0cb12356620ab575775aad89/kiwisolver-1.4.8-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:858416b7fb777a53f0c59ca08190ce24e9abbd3cffa18886a5781b8e3e26f65d", size = 2520077, upload-time = "2024-12-24T18:29:36.138Z" }, + { url = "https://files.pythonhosted.org/packages/32/d8/014b89fee5d4dce157d814303b0fce4d31385a2af4c41fed194b173b81ac/kiwisolver-1.4.8-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:085940635c62697391baafaaeabdf3dd7a6c3643577dde337f4d66eba021b2b8", size = 2338410, upload-time = "2024-12-24T18:29:39.991Z" }, + { url = "https://files.pythonhosted.org/packages/bd/72/dfff0cc97f2a0776e1c9eb5bef1ddfd45f46246c6533b0191887a427bca5/kiwisolver-1.4.8-cp312-cp312-win_amd64.whl", hash = "sha256:01c3d31902c7db5fb6182832713d3b4122ad9317c2c5877d0539227d96bb2e50", size = 71853, upload-time = "2024-12-24T18:29:42.006Z" }, + { url = "https://files.pythonhosted.org/packages/dc/85/220d13d914485c0948a00f0b9eb419efaf6da81b7d72e88ce2391f7aed8d/kiwisolver-1.4.8-cp312-cp312-win_arm64.whl", hash = "sha256:a3c44cb68861de93f0c4a8175fbaa691f0aa22550c331fefef02b618a9dcb476", size = 65424, upload-time = "2024-12-24T18:29:44.38Z" }, + { url = "https://files.pythonhosted.org/packages/79/b3/e62464a652f4f8cd9006e13d07abad844a47df1e6537f73ddfbf1bc997ec/kiwisolver-1.4.8-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:1c8ceb754339793c24aee1c9fb2485b5b1f5bb1c2c214ff13368431e51fc9a09", size = 124156, upload-time = "2024-12-24T18:29:45.368Z" }, + { url = "https://files.pythonhosted.org/packages/8d/2d/f13d06998b546a2ad4f48607a146e045bbe48030774de29f90bdc573df15/kiwisolver-1.4.8-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:54a62808ac74b5e55a04a408cda6156f986cefbcf0ada13572696b507cc92fa1", size = 66555, upload-time = "2024-12-24T18:29:46.37Z" }, + { url = "https://files.pythonhosted.org/packages/59/e3/b8bd14b0a54998a9fd1e8da591c60998dc003618cb19a3f94cb233ec1511/kiwisolver-1.4.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:68269e60ee4929893aad82666821aaacbd455284124817af45c11e50a4b42e3c", size = 65071, upload-time = "2024-12-24T18:29:47.333Z" }, + { url = "https://files.pythonhosted.org/packages/f0/1c/6c86f6d85ffe4d0ce04228d976f00674f1df5dc893bf2dd4f1928748f187/kiwisolver-1.4.8-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:34d142fba9c464bc3bbfeff15c96eab0e7310343d6aefb62a79d51421fcc5f1b", size = 1378053, upload-time = "2024-12-24T18:29:49.636Z" }, + { url = "https://files.pythonhosted.org/packages/4e/b9/1c6e9f6dcb103ac5cf87cb695845f5fa71379021500153566d8a8a9fc291/kiwisolver-1.4.8-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3ddc373e0eef45b59197de815b1b28ef89ae3955e7722cc9710fb91cd77b7f47", size = 1472278, upload-time = "2024-12-24T18:29:51.164Z" }, + { url = "https://files.pythonhosted.org/packages/ee/81/aca1eb176de671f8bda479b11acdc42c132b61a2ac861c883907dde6debb/kiwisolver-1.4.8-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:77e6f57a20b9bd4e1e2cedda4d0b986ebd0216236f0106e55c28aea3d3d69b16", size = 1478139, upload-time = "2024-12-24T18:29:52.594Z" }, + { url = "https://files.pythonhosted.org/packages/49/f4/e081522473671c97b2687d380e9e4c26f748a86363ce5af48b4a28e48d06/kiwisolver-1.4.8-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:08e77738ed7538f036cd1170cbed942ef749137b1311fa2bbe2a7fda2f6bf3cc", size = 1413517, upload-time = "2024-12-24T18:29:53.941Z" }, + { url = "https://files.pythonhosted.org/packages/8f/e9/6a7d025d8da8c4931522922cd706105aa32b3291d1add8c5427cdcd66e63/kiwisolver-1.4.8-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a5ce1e481a74b44dd5e92ff03ea0cb371ae7a0268318e202be06c8f04f4f1246", size = 1474952, upload-time = "2024-12-24T18:29:56.523Z" }, + { url = "https://files.pythonhosted.org/packages/82/13/13fa685ae167bee5d94b415991c4fc7bb0a1b6ebea6e753a87044b209678/kiwisolver-1.4.8-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:fc2ace710ba7c1dfd1a3b42530b62b9ceed115f19a1656adefce7b1782a37794", size = 2269132, upload-time = "2024-12-24T18:29:57.989Z" }, + { url = "https://files.pythonhosted.org/packages/ef/92/bb7c9395489b99a6cb41d502d3686bac692586db2045adc19e45ee64ed23/kiwisolver-1.4.8-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:3452046c37c7692bd52b0e752b87954ef86ee2224e624ef7ce6cb21e8c41cc1b", size = 2425997, upload-time = "2024-12-24T18:29:59.393Z" }, + { url = "https://files.pythonhosted.org/packages/ed/12/87f0e9271e2b63d35d0d8524954145837dd1a6c15b62a2d8c1ebe0f182b4/kiwisolver-1.4.8-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7e9a60b50fe8b2ec6f448fe8d81b07e40141bfced7f896309df271a0b92f80f3", size = 2376060, upload-time = "2024-12-24T18:30:01.338Z" }, + { url = "https://files.pythonhosted.org/packages/02/6e/c8af39288edbce8bf0fa35dee427b082758a4b71e9c91ef18fa667782138/kiwisolver-1.4.8-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:918139571133f366e8362fa4a297aeba86c7816b7ecf0bc79168080e2bd79957", size = 2520471, upload-time = "2024-12-24T18:30:04.574Z" }, + { url = "https://files.pythonhosted.org/packages/13/78/df381bc7b26e535c91469f77f16adcd073beb3e2dd25042efd064af82323/kiwisolver-1.4.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e063ef9f89885a1d68dd8b2e18f5ead48653176d10a0e324e3b0030e3a69adeb", size = 2338793, upload-time = "2024-12-24T18:30:06.25Z" }, + { url = "https://files.pythonhosted.org/packages/d0/dc/c1abe38c37c071d0fc71c9a474fd0b9ede05d42f5a458d584619cfd2371a/kiwisolver-1.4.8-cp313-cp313-win_amd64.whl", hash = "sha256:a17b7c4f5b2c51bb68ed379defd608a03954a1845dfed7cc0117f1cc8a9b7fd2", size = 71855, upload-time = "2024-12-24T18:30:07.535Z" }, + { url = "https://files.pythonhosted.org/packages/a0/b6/21529d595b126ac298fdd90b705d87d4c5693de60023e0efcb4f387ed99e/kiwisolver-1.4.8-cp313-cp313-win_arm64.whl", hash = "sha256:3cd3bc628b25f74aedc6d374d5babf0166a92ff1317f46267f12d2ed54bc1d30", size = 65430, upload-time = "2024-12-24T18:30:08.504Z" }, + { url = "https://files.pythonhosted.org/packages/34/bd/b89380b7298e3af9b39f49334e3e2a4af0e04819789f04b43d560516c0c8/kiwisolver-1.4.8-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:370fd2df41660ed4e26b8c9d6bbcad668fbe2560462cba151a721d49e5b6628c", size = 126294, upload-time = "2024-12-24T18:30:09.508Z" }, + { url = "https://files.pythonhosted.org/packages/83/41/5857dc72e5e4148eaac5aa76e0703e594e4465f8ab7ec0fc60e3a9bb8fea/kiwisolver-1.4.8-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:84a2f830d42707de1d191b9490ac186bf7997a9495d4e9072210a1296345f7dc", size = 67736, upload-time = "2024-12-24T18:30:11.039Z" }, + { url = "https://files.pythonhosted.org/packages/e1/d1/be059b8db56ac270489fb0b3297fd1e53d195ba76e9bbb30e5401fa6b759/kiwisolver-1.4.8-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:7a3ad337add5148cf51ce0b55642dc551c0b9d6248458a757f98796ca7348712", size = 66194, upload-time = "2024-12-24T18:30:14.886Z" }, + { url = "https://files.pythonhosted.org/packages/e1/83/4b73975f149819eb7dcf9299ed467eba068ecb16439a98990dcb12e63fdd/kiwisolver-1.4.8-cp313-cp313t-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7506488470f41169b86d8c9aeff587293f530a23a23a49d6bc64dab66bedc71e", size = 1465942, upload-time = "2024-12-24T18:30:18.927Z" }, + { url = "https://files.pythonhosted.org/packages/c7/2c/30a5cdde5102958e602c07466bce058b9d7cb48734aa7a4327261ac8e002/kiwisolver-1.4.8-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f0121b07b356a22fb0414cec4666bbe36fd6d0d759db3d37228f496ed67c880", size = 1595341, upload-time = "2024-12-24T18:30:22.102Z" }, + { url = "https://files.pythonhosted.org/packages/ff/9b/1e71db1c000385aa069704f5990574b8244cce854ecd83119c19e83c9586/kiwisolver-1.4.8-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d6d6bd87df62c27d4185de7c511c6248040afae67028a8a22012b010bc7ad062", size = 1598455, upload-time = "2024-12-24T18:30:24.947Z" }, + { url = "https://files.pythonhosted.org/packages/85/92/c8fec52ddf06231b31cbb779af77e99b8253cd96bd135250b9498144c78b/kiwisolver-1.4.8-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:291331973c64bb9cce50bbe871fb2e675c4331dab4f31abe89f175ad7679a4d7", size = 1522138, upload-time = "2024-12-24T18:30:26.286Z" }, + { url = "https://files.pythonhosted.org/packages/0b/51/9eb7e2cd07a15d8bdd976f6190c0164f92ce1904e5c0c79198c4972926b7/kiwisolver-1.4.8-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:893f5525bb92d3d735878ec00f781b2de998333659507d29ea4466208df37bed", size = 1582857, upload-time = "2024-12-24T18:30:28.86Z" }, + { url = "https://files.pythonhosted.org/packages/0f/95/c5a00387a5405e68ba32cc64af65ce881a39b98d73cc394b24143bebc5b8/kiwisolver-1.4.8-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b47a465040146981dc9db8647981b8cb96366fbc8d452b031e4f8fdffec3f26d", size = 2293129, upload-time = "2024-12-24T18:30:30.34Z" }, + { url = "https://files.pythonhosted.org/packages/44/83/eeb7af7d706b8347548313fa3a3a15931f404533cc54fe01f39e830dd231/kiwisolver-1.4.8-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:99cea8b9dd34ff80c521aef46a1dddb0dcc0283cf18bde6d756f1e6f31772165", size = 2421538, upload-time = "2024-12-24T18:30:33.334Z" }, + { url = "https://files.pythonhosted.org/packages/05/f9/27e94c1b3eb29e6933b6986ffc5fa1177d2cd1f0c8efc5f02c91c9ac61de/kiwisolver-1.4.8-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:151dffc4865e5fe6dafce5480fab84f950d14566c480c08a53c663a0020504b6", size = 2390661, upload-time = "2024-12-24T18:30:34.939Z" }, + { url = "https://files.pythonhosted.org/packages/d9/d4/3c9735faa36ac591a4afcc2980d2691000506050b7a7e80bcfe44048daa7/kiwisolver-1.4.8-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:577facaa411c10421314598b50413aa1ebcf5126f704f1e5d72d7e4e9f020d90", size = 2546710, upload-time = "2024-12-24T18:30:37.281Z" }, + { url = "https://files.pythonhosted.org/packages/4c/fa/be89a49c640930180657482a74970cdcf6f7072c8d2471e1babe17a222dc/kiwisolver-1.4.8-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:be4816dc51c8a471749d664161b434912eee82f2ea66bd7628bd14583a833e85", size = 2349213, upload-time = "2024-12-24T18:30:40.019Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f9/ae81c47a43e33b93b0a9819cac6723257f5da2a5a60daf46aa5c7226ea85/kiwisolver-1.4.8-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:e7a019419b7b510f0f7c9dceff8c5eae2392037eae483a7f9162625233802b0a", size = 60403, upload-time = "2024-12-24T18:30:41.372Z" }, + { url = "https://files.pythonhosted.org/packages/58/ca/f92b5cb6f4ce0c1ebfcfe3e2e42b96917e16f7090e45b21102941924f18f/kiwisolver-1.4.8-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:286b18e86682fd2217a48fc6be6b0f20c1d0ed10958d8dc53453ad58d7be0bf8", size = 58657, upload-time = "2024-12-24T18:30:42.392Z" }, + { url = "https://files.pythonhosted.org/packages/80/28/ae0240f732f0484d3a4dc885d055653c47144bdf59b670aae0ec3c65a7c8/kiwisolver-1.4.8-pp310-pypy310_pp73-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4191ee8dfd0be1c3666ccbac178c5a05d5f8d689bbe3fc92f3c4abec817f8fe0", size = 84948, upload-time = "2024-12-24T18:30:44.703Z" }, + { url = "https://files.pythonhosted.org/packages/5d/eb/78d50346c51db22c7203c1611f9b513075f35c4e0e4877c5dde378d66043/kiwisolver-1.4.8-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7cd2785b9391f2873ad46088ed7599a6a71e762e1ea33e87514b1a441ed1da1c", size = 81186, upload-time = "2024-12-24T18:30:45.654Z" }, + { url = "https://files.pythonhosted.org/packages/43/f8/7259f18c77adca88d5f64f9a522792e178b2691f3748817a8750c2d216ef/kiwisolver-1.4.8-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c07b29089b7ba090b6f1a669f1411f27221c3662b3a1b7010e67b59bb5a6f10b", size = 80279, upload-time = "2024-12-24T18:30:47.951Z" }, + { url = "https://files.pythonhosted.org/packages/3a/1d/50ad811d1c5dae091e4cf046beba925bcae0a610e79ae4c538f996f63ed5/kiwisolver-1.4.8-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:65ea09a5a3faadd59c2ce96dc7bf0f364986a315949dc6374f04396b0d60e09b", size = 71762, upload-time = "2024-12-24T18:30:48.903Z" }, +] + +[[package]] +name = "markdown" +version = "3.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2f/15/222b423b0b88689c266d9eac4e61396fe2cc53464459d6a37618ac863b24/markdown-3.8.tar.gz", hash = "sha256:7df81e63f0df5c4b24b7d156eb81e4690595239b7d70937d0409f1b0de319c6f", size = 360906, upload-time = "2025-04-11T14:42:50.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/3f/afe76f8e2246ffbc867440cbcf90525264df0e658f8a5ca1f872b3f6192a/markdown-3.8-py3-none-any.whl", hash = "sha256:794a929b79c5af141ef5ab0f2f642d0f7b1872981250230e72682346f7cc90dc", size = 106210, upload-time = "2025-04-11T14:42:49.178Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596, upload-time = "2023-06-03T06:41:14.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528, upload-time = "2023-06-03T06:41:11.019Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/97/5d42485e71dfc078108a86d6de8fa46db44a1a9295e89c5d6d4a06e23a62/markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0", size = 20537, upload-time = "2024-10-18T15:21:54.129Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/90/d08277ce111dd22f77149fd1a5d4653eeb3b3eaacbdfcbae5afb2600eebd/MarkupSafe-3.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7e94c425039cde14257288fd61dcfb01963e658efbc0ff54f5306b06054700f8", size = 14357, upload-time = "2024-10-18T15:20:51.44Z" }, + { url = "https://files.pythonhosted.org/packages/04/e1/6e2194baeae0bca1fae6629dc0cbbb968d4d941469cbab11a3872edff374/MarkupSafe-3.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9e2d922824181480953426608b81967de705c3cef4d1af983af849d7bd619158", size = 12393, upload-time = "2024-10-18T15:20:52.426Z" }, + { url = "https://files.pythonhosted.org/packages/1d/69/35fa85a8ece0a437493dc61ce0bb6d459dcba482c34197e3efc829aa357f/MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38a9ef736c01fccdd6600705b09dc574584b89bea478200c5fbf112a6b0d5579", size = 21732, upload-time = "2024-10-18T15:20:53.578Z" }, + { url = "https://files.pythonhosted.org/packages/22/35/137da042dfb4720b638d2937c38a9c2df83fe32d20e8c8f3185dbfef05f7/MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bbcb445fa71794da8f178f0f6d66789a28d7319071af7a496d4d507ed566270d", size = 20866, upload-time = "2024-10-18T15:20:55.06Z" }, + { url = "https://files.pythonhosted.org/packages/29/28/6d029a903727a1b62edb51863232152fd335d602def598dade38996887f0/MarkupSafe-3.0.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:57cb5a3cf367aeb1d316576250f65edec5bb3be939e9247ae594b4bcbc317dfb", size = 20964, upload-time = "2024-10-18T15:20:55.906Z" }, + { url = "https://files.pythonhosted.org/packages/cc/cd/07438f95f83e8bc028279909d9c9bd39e24149b0d60053a97b2bc4f8aa51/MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3809ede931876f5b2ec92eef964286840ed3540dadf803dd570c3b7e13141a3b", size = 21977, upload-time = "2024-10-18T15:20:57.189Z" }, + { url = "https://files.pythonhosted.org/packages/29/01/84b57395b4cc062f9c4c55ce0df7d3108ca32397299d9df00fedd9117d3d/MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e07c3764494e3776c602c1e78e298937c3315ccc9043ead7e685b7f2b8d47b3c", size = 21366, upload-time = "2024-10-18T15:20:58.235Z" }, + { url = "https://files.pythonhosted.org/packages/bd/6e/61ebf08d8940553afff20d1fb1ba7294b6f8d279df9fd0c0db911b4bbcfd/MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b424c77b206d63d500bcb69fa55ed8d0e6a3774056bdc4839fc9298a7edca171", size = 21091, upload-time = "2024-10-18T15:20:59.235Z" }, + { url = "https://files.pythonhosted.org/packages/11/23/ffbf53694e8c94ebd1e7e491de185124277964344733c45481f32ede2499/MarkupSafe-3.0.2-cp310-cp310-win32.whl", hash = "sha256:fcabf5ff6eea076f859677f5f0b6b5c1a51e70a376b0579e0eadef8db48c6b50", size = 15065, upload-time = "2024-10-18T15:21:00.307Z" }, + { url = "https://files.pythonhosted.org/packages/44/06/e7175d06dd6e9172d4a69a72592cb3f7a996a9c396eee29082826449bbc3/MarkupSafe-3.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:6af100e168aa82a50e186c82875a5893c5597a0c1ccdb0d8b40240b1f28b969a", size = 15514, upload-time = "2024-10-18T15:21:01.122Z" }, + { url = "https://files.pythonhosted.org/packages/6b/28/bbf83e3f76936960b850435576dd5e67034e200469571be53f69174a2dfd/MarkupSafe-3.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9025b4018f3a1314059769c7bf15441064b2207cb3f065e6ea1e7359cb46db9d", size = 14353, upload-time = "2024-10-18T15:21:02.187Z" }, + { url = "https://files.pythonhosted.org/packages/6c/30/316d194b093cde57d448a4c3209f22e3046c5bb2fb0820b118292b334be7/MarkupSafe-3.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:93335ca3812df2f366e80509ae119189886b0f3c2b81325d39efdb84a1e2ae93", size = 12392, upload-time = "2024-10-18T15:21:02.941Z" }, + { url = "https://files.pythonhosted.org/packages/f2/96/9cdafba8445d3a53cae530aaf83c38ec64c4d5427d975c974084af5bc5d2/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2cb8438c3cbb25e220c2ab33bb226559e7afb3baec11c4f218ffa7308603c832", size = 23984, upload-time = "2024-10-18T15:21:03.953Z" }, + { url = "https://files.pythonhosted.org/packages/f1/a4/aefb044a2cd8d7334c8a47d3fb2c9f328ac48cb349468cc31c20b539305f/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a123e330ef0853c6e822384873bef7507557d8e4a082961e1defa947aa59ba84", size = 23120, upload-time = "2024-10-18T15:21:06.495Z" }, + { url = "https://files.pythonhosted.org/packages/8d/21/5e4851379f88f3fad1de30361db501300d4f07bcad047d3cb0449fc51f8c/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e084f686b92e5b83186b07e8a17fc09e38fff551f3602b249881fec658d3eca", size = 23032, upload-time = "2024-10-18T15:21:07.295Z" }, + { url = "https://files.pythonhosted.org/packages/00/7b/e92c64e079b2d0d7ddf69899c98842f3f9a60a1ae72657c89ce2655c999d/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8213e09c917a951de9d09ecee036d5c7d36cb6cb7dbaece4c71a60d79fb9798", size = 24057, upload-time = "2024-10-18T15:21:08.073Z" }, + { url = "https://files.pythonhosted.org/packages/f9/ac/46f960ca323037caa0a10662ef97d0a4728e890334fc156b9f9e52bcc4ca/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5b02fb34468b6aaa40dfc198d813a641e3a63b98c2b05a16b9f80b7ec314185e", size = 23359, upload-time = "2024-10-18T15:21:09.318Z" }, + { url = "https://files.pythonhosted.org/packages/69/84/83439e16197337b8b14b6a5b9c2105fff81d42c2a7c5b58ac7b62ee2c3b1/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0bff5e0ae4ef2e1ae4fdf2dfd5b76c75e5c2fa4132d05fc1b0dabcd20c7e28c4", size = 23306, upload-time = "2024-10-18T15:21:10.185Z" }, + { url = "https://files.pythonhosted.org/packages/9a/34/a15aa69f01e2181ed8d2b685c0d2f6655d5cca2c4db0ddea775e631918cd/MarkupSafe-3.0.2-cp311-cp311-win32.whl", hash = "sha256:6c89876f41da747c8d3677a2b540fb32ef5715f97b66eeb0c6b66f5e3ef6f59d", size = 15094, upload-time = "2024-10-18T15:21:11.005Z" }, + { url = "https://files.pythonhosted.org/packages/da/b8/3a3bd761922d416f3dc5d00bfbed11f66b1ab89a0c2b6e887240a30b0f6b/MarkupSafe-3.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:70a87b411535ccad5ef2f1df5136506a10775d267e197e4cf531ced10537bd6b", size = 15521, upload-time = "2024-10-18T15:21:12.911Z" }, + { url = "https://files.pythonhosted.org/packages/22/09/d1f21434c97fc42f09d290cbb6350d44eb12f09cc62c9476effdb33a18aa/MarkupSafe-3.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf", size = 14274, upload-time = "2024-10-18T15:21:13.777Z" }, + { url = "https://files.pythonhosted.org/packages/6b/b0/18f76bba336fa5aecf79d45dcd6c806c280ec44538b3c13671d49099fdd0/MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225", size = 12348, upload-time = "2024-10-18T15:21:14.822Z" }, + { url = "https://files.pythonhosted.org/packages/e0/25/dd5c0f6ac1311e9b40f4af06c78efde0f3b5cbf02502f8ef9501294c425b/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028", size = 24149, upload-time = "2024-10-18T15:21:15.642Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f0/89e7aadfb3749d0f52234a0c8c7867877876e0a20b60e2188e9850794c17/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8", size = 23118, upload-time = "2024-10-18T15:21:17.133Z" }, + { url = "https://files.pythonhosted.org/packages/d5/da/f2eeb64c723f5e3777bc081da884b414671982008c47dcc1873d81f625b6/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c", size = 22993, upload-time = "2024-10-18T15:21:18.064Z" }, + { url = "https://files.pythonhosted.org/packages/da/0e/1f32af846df486dce7c227fe0f2398dc7e2e51d4a370508281f3c1c5cddc/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557", size = 24178, upload-time = "2024-10-18T15:21:18.859Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f6/bb3ca0532de8086cbff5f06d137064c8410d10779c4c127e0e47d17c0b71/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22", size = 23319, upload-time = "2024-10-18T15:21:19.671Z" }, + { url = "https://files.pythonhosted.org/packages/a2/82/8be4c96ffee03c5b4a034e60a31294daf481e12c7c43ab8e34a1453ee48b/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48", size = 23352, upload-time = "2024-10-18T15:21:20.971Z" }, + { url = "https://files.pythonhosted.org/packages/51/ae/97827349d3fcffee7e184bdf7f41cd6b88d9919c80f0263ba7acd1bbcb18/MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30", size = 15097, upload-time = "2024-10-18T15:21:22.646Z" }, + { url = "https://files.pythonhosted.org/packages/c1/80/a61f99dc3a936413c3ee4e1eecac96c0da5ed07ad56fd975f1a9da5bc630/MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87", size = 15601, upload-time = "2024-10-18T15:21:23.499Z" }, + { url = "https://files.pythonhosted.org/packages/83/0e/67eb10a7ecc77a0c2bbe2b0235765b98d164d81600746914bebada795e97/MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd", size = 14274, upload-time = "2024-10-18T15:21:24.577Z" }, + { url = "https://files.pythonhosted.org/packages/2b/6d/9409f3684d3335375d04e5f05744dfe7e9f120062c9857df4ab490a1031a/MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430", size = 12352, upload-time = "2024-10-18T15:21:25.382Z" }, + { url = "https://files.pythonhosted.org/packages/d2/f5/6eadfcd3885ea85fe2a7c128315cc1bb7241e1987443d78c8fe712d03091/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094", size = 24122, upload-time = "2024-10-18T15:21:26.199Z" }, + { url = "https://files.pythonhosted.org/packages/0c/91/96cf928db8236f1bfab6ce15ad070dfdd02ed88261c2afafd4b43575e9e9/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396", size = 23085, upload-time = "2024-10-18T15:21:27.029Z" }, + { url = "https://files.pythonhosted.org/packages/c2/cf/c9d56af24d56ea04daae7ac0940232d31d5a8354f2b457c6d856b2057d69/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79", size = 22978, upload-time = "2024-10-18T15:21:27.846Z" }, + { url = "https://files.pythonhosted.org/packages/2a/9f/8619835cd6a711d6272d62abb78c033bda638fdc54c4e7f4272cf1c0962b/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a", size = 24208, upload-time = "2024-10-18T15:21:28.744Z" }, + { url = "https://files.pythonhosted.org/packages/f9/bf/176950a1792b2cd2102b8ffeb5133e1ed984547b75db47c25a67d3359f77/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca", size = 23357, upload-time = "2024-10-18T15:21:29.545Z" }, + { url = "https://files.pythonhosted.org/packages/ce/4f/9a02c1d335caabe5c4efb90e1b6e8ee944aa245c1aaaab8e8a618987d816/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c", size = 23344, upload-time = "2024-10-18T15:21:30.366Z" }, + { url = "https://files.pythonhosted.org/packages/ee/55/c271b57db36f748f0e04a759ace9f8f759ccf22b4960c270c78a394f58be/MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1", size = 15101, upload-time = "2024-10-18T15:21:31.207Z" }, + { url = "https://files.pythonhosted.org/packages/29/88/07df22d2dd4df40aba9f3e402e6dc1b8ee86297dddbad4872bd5e7b0094f/MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f", size = 15603, upload-time = "2024-10-18T15:21:32.032Z" }, + { url = "https://files.pythonhosted.org/packages/62/6a/8b89d24db2d32d433dffcd6a8779159da109842434f1dd2f6e71f32f738c/MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c", size = 14510, upload-time = "2024-10-18T15:21:33.625Z" }, + { url = "https://files.pythonhosted.org/packages/7a/06/a10f955f70a2e5a9bf78d11a161029d278eeacbd35ef806c3fd17b13060d/MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb", size = 12486, upload-time = "2024-10-18T15:21:34.611Z" }, + { url = "https://files.pythonhosted.org/packages/34/cf/65d4a571869a1a9078198ca28f39fba5fbb910f952f9dbc5220afff9f5e6/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c", size = 25480, upload-time = "2024-10-18T15:21:35.398Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e3/90e9651924c430b885468b56b3d597cabf6d72be4b24a0acd1fa0e12af67/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d", size = 23914, upload-time = "2024-10-18T15:21:36.231Z" }, + { url = "https://files.pythonhosted.org/packages/66/8c/6c7cf61f95d63bb866db39085150df1f2a5bd3335298f14a66b48e92659c/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe", size = 23796, upload-time = "2024-10-18T15:21:37.073Z" }, + { url = "https://files.pythonhosted.org/packages/bb/35/cbe9238ec3f47ac9a7c8b3df7a808e7cb50fe149dc7039f5f454b3fba218/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5", size = 25473, upload-time = "2024-10-18T15:21:37.932Z" }, + { url = "https://files.pythonhosted.org/packages/e6/32/7621a4382488aa283cc05e8984a9c219abad3bca087be9ec77e89939ded9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a", size = 24114, upload-time = "2024-10-18T15:21:39.799Z" }, + { url = "https://files.pythonhosted.org/packages/0d/80/0985960e4b89922cb5a0bac0ed39c5b96cbc1a536a99f30e8c220a996ed9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9", size = 24098, upload-time = "2024-10-18T15:21:40.813Z" }, + { url = "https://files.pythonhosted.org/packages/82/78/fedb03c7d5380df2427038ec8d973587e90561b2d90cd472ce9254cf348b/MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6", size = 15208, upload-time = "2024-10-18T15:21:41.814Z" }, + { url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739, upload-time = "2024-10-18T15:21:42.784Z" }, +] + +[[package]] +name = "matplotlib" +version = "3.10.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "contourpy" }, + { name = "cycler" }, + { name = "fonttools" }, + { name = "kiwisolver" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "pillow" }, + { name = "pyparsing" }, + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/26/91/d49359a21893183ed2a5b6c76bec40e0b1dcbf8ca148f864d134897cfc75/matplotlib-3.10.3.tar.gz", hash = "sha256:2f82d2c5bb7ae93aaaa4cd42aca65d76ce6376f83304fa3a630b569aca274df0", size = 34799811, upload-time = "2025-05-08T19:10:54.39Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/ea/2bba25d289d389c7451f331ecd593944b3705f06ddf593fa7be75037d308/matplotlib-3.10.3-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:213fadd6348d106ca7db99e113f1bea1e65e383c3ba76e8556ba4a3054b65ae7", size = 8167862, upload-time = "2025-05-08T19:09:39.563Z" }, + { url = "https://files.pythonhosted.org/packages/41/81/cc70b5138c926604e8c9ed810ed4c79e8116ba72e02230852f5c12c87ba2/matplotlib-3.10.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d3bec61cb8221f0ca6313889308326e7bb303d0d302c5cc9e523b2f2e6c73deb", size = 8042149, upload-time = "2025-05-08T19:09:42.413Z" }, + { url = "https://files.pythonhosted.org/packages/4a/9a/0ff45b6bfa42bb16de597e6058edf2361c298ad5ef93b327728145161bbf/matplotlib-3.10.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c21ae75651c0231b3ba014b6d5e08fb969c40cdb5a011e33e99ed0c9ea86ecb", size = 8453719, upload-time = "2025-05-08T19:09:44.901Z" }, + { url = "https://files.pythonhosted.org/packages/85/c7/1866e972fed6d71ef136efbc980d4d1854ab7ef1ea8152bbd995ca231c81/matplotlib-3.10.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a49e39755580b08e30e3620efc659330eac5d6534ab7eae50fa5e31f53ee4e30", size = 8590801, upload-time = "2025-05-08T19:09:47.404Z" }, + { url = "https://files.pythonhosted.org/packages/5d/b9/748f6626d534ab7e255bdc39dc22634d337cf3ce200f261b5d65742044a1/matplotlib-3.10.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cf4636203e1190871d3a73664dea03d26fb019b66692cbfd642faafdad6208e8", size = 9402111, upload-time = "2025-05-08T19:09:49.474Z" }, + { url = "https://files.pythonhosted.org/packages/1f/78/8bf07bd8fb67ea5665a6af188e70b57fcb2ab67057daa06b85a08e59160a/matplotlib-3.10.3-cp310-cp310-win_amd64.whl", hash = "sha256:fd5641a9bb9d55f4dd2afe897a53b537c834b9012684c8444cc105895c8c16fd", size = 8057213, upload-time = "2025-05-08T19:09:51.489Z" }, + { url = "https://files.pythonhosted.org/packages/f5/bd/af9f655456f60fe1d575f54fb14704ee299b16e999704817a7645dfce6b0/matplotlib-3.10.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0ef061f74cd488586f552d0c336b2f078d43bc00dc473d2c3e7bfee2272f3fa8", size = 8178873, upload-time = "2025-05-08T19:09:53.857Z" }, + { url = "https://files.pythonhosted.org/packages/c2/86/e1c86690610661cd716eda5f9d0b35eaf606ae6c9b6736687cfc8f2d0cd8/matplotlib-3.10.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d96985d14dc5f4a736bbea4b9de9afaa735f8a0fc2ca75be2fa9e96b2097369d", size = 8052205, upload-time = "2025-05-08T19:09:55.684Z" }, + { url = "https://files.pythonhosted.org/packages/54/51/a9f8e49af3883dacddb2da1af5fca1f7468677f1188936452dd9aaaeb9ed/matplotlib-3.10.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7c5f0283da91e9522bdba4d6583ed9d5521566f63729ffb68334f86d0bb98049", size = 8465823, upload-time = "2025-05-08T19:09:57.442Z" }, + { url = "https://files.pythonhosted.org/packages/e7/e3/c82963a3b86d6e6d5874cbeaa390166458a7f1961bab9feb14d3d1a10f02/matplotlib-3.10.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fdfa07c0ec58035242bc8b2c8aae37037c9a886370eef6850703d7583e19964b", size = 8606464, upload-time = "2025-05-08T19:09:59.471Z" }, + { url = "https://files.pythonhosted.org/packages/0e/34/24da1027e7fcdd9e82da3194c470143c551852757a4b473a09a012f5b945/matplotlib-3.10.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c0b9849a17bce080a16ebcb80a7b714b5677d0ec32161a2cc0a8e5a6030ae220", size = 9413103, upload-time = "2025-05-08T19:10:03.208Z" }, + { url = "https://files.pythonhosted.org/packages/a6/da/948a017c3ea13fd4a97afad5fdebe2f5bbc4d28c0654510ce6fd6b06b7bd/matplotlib-3.10.3-cp311-cp311-win_amd64.whl", hash = "sha256:eef6ed6c03717083bc6d69c2d7ee8624205c29a8e6ea5a31cd3492ecdbaee1e1", size = 8065492, upload-time = "2025-05-08T19:10:05.271Z" }, + { url = "https://files.pythonhosted.org/packages/eb/43/6b80eb47d1071f234ef0c96ca370c2ca621f91c12045f1401b5c9b28a639/matplotlib-3.10.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0ab1affc11d1f495ab9e6362b8174a25afc19c081ba5b0775ef00533a4236eea", size = 8179689, upload-time = "2025-05-08T19:10:07.602Z" }, + { url = "https://files.pythonhosted.org/packages/0f/70/d61a591958325c357204870b5e7b164f93f2a8cca1dc6ce940f563909a13/matplotlib-3.10.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2a818d8bdcafa7ed2eed74487fdb071c09c1ae24152d403952adad11fa3c65b4", size = 8050466, upload-time = "2025-05-08T19:10:09.383Z" }, + { url = "https://files.pythonhosted.org/packages/e7/75/70c9d2306203148cc7902a961240c5927dd8728afedf35e6a77e105a2985/matplotlib-3.10.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:748ebc3470c253e770b17d8b0557f0aa85cf8c63fd52f1a61af5b27ec0b7ffee", size = 8456252, upload-time = "2025-05-08T19:10:11.958Z" }, + { url = "https://files.pythonhosted.org/packages/c4/91/ba0ae1ff4b3f30972ad01cd4a8029e70a0ec3b8ea5be04764b128b66f763/matplotlib-3.10.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ed70453fd99733293ace1aec568255bc51c6361cb0da94fa5ebf0649fdb2150a", size = 8601321, upload-time = "2025-05-08T19:10:14.47Z" }, + { url = "https://files.pythonhosted.org/packages/d2/88/d636041eb54a84b889e11872d91f7cbf036b3b0e194a70fa064eb8b04f7a/matplotlib-3.10.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dbed9917b44070e55640bd13419de83b4c918e52d97561544814ba463811cbc7", size = 9406972, upload-time = "2025-05-08T19:10:16.569Z" }, + { url = "https://files.pythonhosted.org/packages/b1/79/0d1c165eac44405a86478082e225fce87874f7198300bbebc55faaf6d28d/matplotlib-3.10.3-cp312-cp312-win_amd64.whl", hash = "sha256:cf37d8c6ef1a48829443e8ba5227b44236d7fcaf7647caa3178a4ff9f7a5be05", size = 8067954, upload-time = "2025-05-08T19:10:18.663Z" }, + { url = "https://files.pythonhosted.org/packages/3b/c1/23cfb566a74c696a3b338d8955c549900d18fe2b898b6e94d682ca21e7c2/matplotlib-3.10.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9f2efccc8dcf2b86fc4ee849eea5dcaecedd0773b30f47980dc0cbeabf26ec84", size = 8180318, upload-time = "2025-05-08T19:10:20.426Z" }, + { url = "https://files.pythonhosted.org/packages/6c/0c/02f1c3b66b30da9ee343c343acbb6251bef5b01d34fad732446eaadcd108/matplotlib-3.10.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3ddbba06a6c126e3301c3d272a99dcbe7f6c24c14024e80307ff03791a5f294e", size = 8051132, upload-time = "2025-05-08T19:10:22.569Z" }, + { url = "https://files.pythonhosted.org/packages/b4/ab/8db1a5ac9b3a7352fb914133001dae889f9fcecb3146541be46bed41339c/matplotlib-3.10.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:748302b33ae9326995b238f606e9ed840bf5886ebafcb233775d946aa8107a15", size = 8457633, upload-time = "2025-05-08T19:10:24.749Z" }, + { url = "https://files.pythonhosted.org/packages/f5/64/41c4367bcaecbc03ef0d2a3ecee58a7065d0a36ae1aa817fe573a2da66d4/matplotlib-3.10.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a80fcccbef63302c0efd78042ea3c2436104c5b1a4d3ae20f864593696364ac7", size = 8601031, upload-time = "2025-05-08T19:10:27.03Z" }, + { url = "https://files.pythonhosted.org/packages/12/6f/6cc79e9e5ab89d13ed64da28898e40fe5b105a9ab9c98f83abd24e46d7d7/matplotlib-3.10.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:55e46cbfe1f8586adb34f7587c3e4f7dedc59d5226719faf6cb54fc24f2fd52d", size = 9406988, upload-time = "2025-05-08T19:10:29.056Z" }, + { url = "https://files.pythonhosted.org/packages/b1/0f/eed564407bd4d935ffabf561ed31099ed609e19287409a27b6d336848653/matplotlib-3.10.3-cp313-cp313-win_amd64.whl", hash = "sha256:151d89cb8d33cb23345cd12490c76fd5d18a56581a16d950b48c6ff19bb2ab93", size = 8068034, upload-time = "2025-05-08T19:10:31.221Z" }, + { url = "https://files.pythonhosted.org/packages/3e/e5/2f14791ff69b12b09e9975e1d116d9578ac684460860ce542c2588cb7a1c/matplotlib-3.10.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:c26dd9834e74d164d06433dc7be5d75a1e9890b926b3e57e74fa446e1a62c3e2", size = 8218223, upload-time = "2025-05-08T19:10:33.114Z" }, + { url = "https://files.pythonhosted.org/packages/5c/08/30a94afd828b6e02d0a52cae4a29d6e9ccfcf4c8b56cc28b021d3588873e/matplotlib-3.10.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:24853dad5b8c84c8c2390fc31ce4858b6df504156893292ce8092d190ef8151d", size = 8094985, upload-time = "2025-05-08T19:10:35.337Z" }, + { url = "https://files.pythonhosted.org/packages/89/44/f3bc6b53066c889d7a1a3ea8094c13af6a667c5ca6220ec60ecceec2dabe/matplotlib-3.10.3-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:68f7878214d369d7d4215e2a9075fef743be38fa401d32e6020bab2dfabaa566", size = 8483109, upload-time = "2025-05-08T19:10:37.611Z" }, + { url = "https://files.pythonhosted.org/packages/ba/c7/473bc559beec08ebee9f86ca77a844b65747e1a6c2691e8c92e40b9f42a8/matplotlib-3.10.3-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f6929fc618cb6db9cb75086f73b3219bbb25920cb24cee2ea7a12b04971a4158", size = 8618082, upload-time = "2025-05-08T19:10:39.892Z" }, + { url = "https://files.pythonhosted.org/packages/d8/e9/6ce8edd264c8819e37bbed8172e0ccdc7107fe86999b76ab5752276357a4/matplotlib-3.10.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6c7818292a5cc372a2dc4c795e5c356942eb8350b98ef913f7fda51fe175ac5d", size = 9413699, upload-time = "2025-05-08T19:10:42.376Z" }, + { url = "https://files.pythonhosted.org/packages/1b/92/9a45c91089c3cf690b5badd4be81e392ff086ccca8a1d4e3a08463d8a966/matplotlib-3.10.3-cp313-cp313t-win_amd64.whl", hash = "sha256:4f23ffe95c5667ef8a2b56eea9b53db7f43910fa4a2d5472ae0f72b64deab4d5", size = 8139044, upload-time = "2025-05-08T19:10:44.551Z" }, + { url = "https://files.pythonhosted.org/packages/3d/d1/f54d43e95384b312ffa4a74a4326c722f3b8187aaaa12e9a84cdf3037131/matplotlib-3.10.3-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:86ab63d66bbc83fdb6733471d3bff40897c1e9921cba112accd748eee4bce5e4", size = 8162896, upload-time = "2025-05-08T19:10:46.432Z" }, + { url = "https://files.pythonhosted.org/packages/24/a4/fbfc00c2346177c95b353dcf9b5a004106abe8730a62cb6f27e79df0a698/matplotlib-3.10.3-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:a48f9c08bf7444b5d2391a83e75edb464ccda3c380384b36532a0962593a1751", size = 8039702, upload-time = "2025-05-08T19:10:49.634Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b9/59e120d24a2ec5fc2d30646adb2efb4621aab3c6d83d66fb2a7a182db032/matplotlib-3.10.3-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cb73d8aa75a237457988f9765e4dfe1c0d2453c5ca4eabc897d4309672c8e014", size = 8594298, upload-time = "2025-05-08T19:10:51.738Z" }, +] + +[[package]] +name = "matplotlib-inline" +version = "0.1.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/99/5b/a36a337438a14116b16480db471ad061c36c3694df7c2084a0da7ba538b7/matplotlib_inline-0.1.7.tar.gz", hash = "sha256:8423b23ec666be3d16e16b60bdd8ac4e86e840ebd1dd11a30b9f117f2fa0ab90", size = 8159, upload-time = "2024-04-15T13:44:44.803Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/8e/9ad090d3553c280a8060fbf6e24dc1c0c29704ee7d1c372f0c174aa59285/matplotlib_inline-0.1.7-py3-none-any.whl", hash = "sha256:df192d39a4ff8f21b1895d72e6a13f5fcc5099f00fa84384e0ea28c2cc0653ca", size = 9899, upload-time = "2024-04-15T13:44:43.265Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "mergedeep" +version = "1.3.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3a/41/580bb4006e3ed0361b8151a01d324fb03f420815446c7def45d02f74c270/mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8", size = 4661, upload-time = "2021-02-05T18:55:30.623Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/19/04f9b178c2d8a15b076c8b5140708fa6ffc5601fb6f1e975537072df5b2a/mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307", size = 6354, upload-time = "2021-02-05T18:55:29.583Z" }, +] + +[[package]] +name = "mkdocs" +version = "1.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "ghp-import" }, + { name = "jinja2" }, + { name = "markdown" }, + { name = "markupsafe" }, + { name = "mergedeep" }, + { name = "mkdocs-get-deps" }, + { name = "packaging" }, + { name = "pathspec" }, + { name = "pyyaml" }, + { name = "pyyaml-env-tag" }, + { name = "watchdog" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bc/c6/bbd4f061bd16b378247f12953ffcb04786a618ce5e904b8c5a01a0309061/mkdocs-1.6.1.tar.gz", hash = "sha256:7b432f01d928c084353ab39c57282f29f92136665bdd6abf7c1ec8d822ef86f2", size = 3889159, upload-time = "2024-08-30T12:24:06.899Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/5b/dbc6a8cddc9cfa9c4971d59fb12bb8d42e161b7e7f8cc89e49137c5b279c/mkdocs-1.6.1-py3-none-any.whl", hash = "sha256:db91759624d1647f3f34aa0c3f327dd2601beae39a366d6e064c03468d35c20e", size = 3864451, upload-time = "2024-08-30T12:24:05.054Z" }, +] + +[[package]] +name = "mkdocs-autorefs" +version = "1.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown" }, + { name = "markupsafe" }, + { name = "mkdocs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/47/0c/c9826f35b99c67fa3a7cddfa094c1a6c43fafde558c309c6e4403e5b37dc/mkdocs_autorefs-1.4.2.tar.gz", hash = "sha256:e2ebe1abd2b67d597ed19378c0fff84d73d1dbce411fce7a7cc6f161888b6749", size = 54961, upload-time = "2025-05-20T13:09:09.886Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/dc/fc063b78f4b769d1956319351704e23ebeba1e9e1d6a41b4b602325fd7e4/mkdocs_autorefs-1.4.2-py3-none-any.whl", hash = "sha256:83d6d777b66ec3c372a1aad4ae0cf77c243ba5bcda5bf0c6b8a2c5e7a3d89f13", size = 24969, upload-time = "2025-05-20T13:09:08.237Z" }, +] + +[[package]] +name = "mkdocs-get-deps" +version = "0.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mergedeep" }, + { name = "platformdirs" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/98/f5/ed29cd50067784976f25ed0ed6fcd3c2ce9eb90650aa3b2796ddf7b6870b/mkdocs_get_deps-0.2.0.tar.gz", hash = "sha256:162b3d129c7fad9b19abfdcb9c1458a651628e4b1dea628ac68790fb3061c60c", size = 10239, upload-time = "2023-11-20T17:51:09.981Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/d4/029f984e8d3f3b6b726bd33cafc473b75e9e44c0f7e80a5b29abc466bdea/mkdocs_get_deps-0.2.0-py3-none-any.whl", hash = "sha256:2bf11d0b133e77a0dd036abeeb06dec8775e46efa526dc70667d8863eefc6134", size = 9521, upload-time = "2023-11-20T17:51:08.587Z" }, +] + +[[package]] +name = "mkdocs-include-markdown-plugin" +version = "6.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mkdocs" }, + { name = "wcmatch" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ee/fe/4bb438d0f58995f81e2616d640f7efe0df9b1f992cba706a9453676c9140/mkdocs_include_markdown_plugin-6.2.2.tar.gz", hash = "sha256:f2bd5026650492a581d2fd44be6c22f90391910d76582b96a34c264f2d17875d", size = 21045, upload-time = "2024-08-10T23:36:41.503Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/50/d9/7b2b09b4870a2cd5a80628c74553307205a8474aabe128b66e305b56ac30/mkdocs_include_markdown_plugin-6.2.2-py3-none-any.whl", hash = "sha256:d293950f6499d2944291ca7b9bc4a60e652bbfd3e3a42b564f6cceee268694e7", size = 24643, upload-time = "2024-08-10T23:36:39.736Z" }, +] + +[[package]] +name = "mkdocs-material" +version = "9.6.14" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "babel" }, + { name = "backrefs" }, + { name = "colorama" }, + { name = "jinja2" }, + { name = "markdown" }, + { name = "mkdocs" }, + { name = "mkdocs-material-extensions" }, + { name = "paginate" }, + { name = "pygments" }, + { name = "pymdown-extensions" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fa/0101de32af88f87cf5cc23ad5f2e2030d00995f74e616306513431b8ab4b/mkdocs_material-9.6.14.tar.gz", hash = "sha256:39d795e90dce6b531387c255bd07e866e027828b7346d3eba5ac3de265053754", size = 3951707, upload-time = "2025-05-13T13:27:57.173Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/a1/7fdb959ad592e013c01558822fd3c22931a95a0f08cf0a7c36da13a5b2b5/mkdocs_material-9.6.14-py3-none-any.whl", hash = "sha256:3b9cee6d3688551bf7a8e8f41afda97a3c39a12f0325436d76c86706114b721b", size = 8703767, upload-time = "2025-05-13T13:27:54.089Z" }, +] + +[[package]] +name = "mkdocs-material-extensions" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/79/9b/9b4c96d6593b2a541e1cb8b34899a6d021d208bb357042823d4d2cabdbe7/mkdocs_material_extensions-1.3.1.tar.gz", hash = "sha256:10c9511cea88f568257f960358a467d12b970e1f7b2c0e5fb2bb48cab1928443", size = 11847, upload-time = "2023-11-22T19:09:45.208Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/54/662a4743aa81d9582ee9339d4ffa3c8fd40a4965e033d77b9da9774d3960/mkdocs_material_extensions-1.3.1-py3-none-any.whl", hash = "sha256:adff8b62700b25cb77b53358dad940f3ef973dd6db797907c49e3c2ef3ab4e31", size = 8728, upload-time = "2023-11-22T19:09:43.465Z" }, +] + +[[package]] +name = "mkdocstrings" +version = "0.29.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jinja2" }, + { name = "markdown" }, + { name = "markupsafe" }, + { name = "mkdocs" }, + { name = "mkdocs-autorefs" }, + { name = "pymdown-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/41/e8/d22922664a627a0d3d7ff4a6ca95800f5dde54f411982591b4621a76225d/mkdocstrings-0.29.1.tar.gz", hash = "sha256:8722f8f8c5cd75da56671e0a0c1bbed1df9946c0cef74794d6141b34011abd42", size = 1212686, upload-time = "2025-03-31T08:33:11.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/14/22533a578bf8b187e05d67e2c1721ce10e3f526610eebaf7a149d557ea7a/mkdocstrings-0.29.1-py3-none-any.whl", hash = "sha256:37a9736134934eea89cbd055a513d40a020d87dfcae9e3052c2a6b8cd4af09b6", size = 1631075, upload-time = "2025-03-31T08:33:09.661Z" }, +] + +[package.optional-dependencies] +python = [ + { name = "mkdocstrings-python" }, +] + +[[package]] +name = "mkdocstrings-python" +version = "1.16.11" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "griffe" }, + { name = "mkdocs-autorefs" }, + { name = "mkdocstrings" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/a3/0c7559a355fa21127a174a5aa2d3dca2de6e479ddd9c63ca4082d5f9980c/mkdocstrings_python-1.16.11.tar.gz", hash = "sha256:935f95efa887f99178e4a7becaaa1286fb35adafffd669b04fd611d97c00e5ce", size = 205392, upload-time = "2025-05-24T10:41:32.078Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/c4/ffa32f2c7cdb1728026c7a34aab87796b895767893aaa54611a79b4eef45/mkdocstrings_python-1.16.11-py3-none-any.whl", hash = "sha256:25d96cc9c1f9c272ea1bd8222c900b5f852bf46c984003e9c7c56eaa4696190f", size = 124282, upload-time = "2025-05-24T10:41:30.008Z" }, +] + +[[package]] +name = "ml-dtypes" +version = "0.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/32/49/6e67c334872d2c114df3020e579f3718c333198f8312290e09ec0216703a/ml_dtypes-0.5.1.tar.gz", hash = "sha256:ac5b58559bb84a95848ed6984eb8013249f90b6bab62aa5acbad876e256002c9", size = 698772, upload-time = "2025-01-07T03:34:55.613Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/88/11ebdbc75445eeb5b6869b708a0d787d1ed812ff86c2170bbfb95febdce1/ml_dtypes-0.5.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:bd73f51957949069573ff783563486339a9285d72e2f36c18e0c1aa9ca7eb190", size = 671450, upload-time = "2025-01-07T03:33:52.724Z" }, + { url = "https://files.pythonhosted.org/packages/a4/a4/9321cae435d6140f9b0e7af8334456a854b60e3a9c6101280a16e3594965/ml_dtypes-0.5.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:810512e2eccdfc3b41eefa3a27402371a3411453a1efc7e9c000318196140fed", size = 4621075, upload-time = "2025-01-07T03:33:54.878Z" }, + { url = "https://files.pythonhosted.org/packages/16/d8/4502e12c6a10d42e13a552e8d97f20198e3cf82a0d1411ad50be56a5077c/ml_dtypes-0.5.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:141b2ea2f20bb10802ddca55d91fe21231ef49715cfc971998e8f2a9838f3dbe", size = 4738414, upload-time = "2025-01-07T03:33:57.709Z" }, + { url = "https://files.pythonhosted.org/packages/6b/7e/bc54ae885e4d702e60a4bf50aa9066ff35e9c66b5213d11091f6bffb3036/ml_dtypes-0.5.1-cp310-cp310-win_amd64.whl", hash = "sha256:26ebcc69d7b779c8f129393e99732961b5cc33fcff84090451f448c89b0e01b4", size = 209718, upload-time = "2025-01-07T03:34:00.585Z" }, + { url = "https://files.pythonhosted.org/packages/c9/fd/691335926126bb9beeb030b61a28f462773dcf16b8e8a2253b599013a303/ml_dtypes-0.5.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:023ce2f502efd4d6c1e0472cc58ce3640d051d40e71e27386bed33901e201327", size = 671448, upload-time = "2025-01-07T03:34:03.153Z" }, + { url = "https://files.pythonhosted.org/packages/ff/a6/63832d91f2feb250d865d069ba1a5d0c686b1f308d1c74ce9764472c5e22/ml_dtypes-0.5.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7000b6e4d8ef07542c05044ec5d8bbae1df083b3f56822c3da63993a113e716f", size = 4625792, upload-time = "2025-01-07T03:34:04.981Z" }, + { url = "https://files.pythonhosted.org/packages/cc/2a/5421fd3dbe6eef9b844cc9d05f568b9fb568503a2e51cb1eb4443d9fc56b/ml_dtypes-0.5.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c09526488c3a9e8b7a23a388d4974b670a9a3dd40c5c8a61db5593ce9b725bab", size = 4743893, upload-time = "2025-01-07T03:34:08.333Z" }, + { url = "https://files.pythonhosted.org/packages/60/30/d3f0fc9499a22801219679a7f3f8d59f1429943c6261f445fb4bfce20718/ml_dtypes-0.5.1-cp311-cp311-win_amd64.whl", hash = "sha256:15ad0f3b0323ce96c24637a88a6f44f6713c64032f27277b069f285c3cf66478", size = 209712, upload-time = "2025-01-07T03:34:12.182Z" }, + { url = "https://files.pythonhosted.org/packages/47/56/1bb21218e1e692506c220ffabd456af9733fba7aa1b14f73899979f4cc20/ml_dtypes-0.5.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:6f462f5eca22fb66d7ff9c4744a3db4463af06c49816c4b6ac89b16bfcdc592e", size = 670372, upload-time = "2025-01-07T03:34:15.258Z" }, + { url = "https://files.pythonhosted.org/packages/20/95/d8bd96a3b60e00bf31bd78ca4bdd2d6bbaf5acb09b42844432d719d34061/ml_dtypes-0.5.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6f76232163b5b9c34291b54621ee60417601e2e4802a188a0ea7157cd9b323f4", size = 4635946, upload-time = "2025-01-07T03:34:20.412Z" }, + { url = "https://files.pythonhosted.org/packages/08/57/5d58fad4124192b1be42f68bd0c0ddaa26e44a730ff8c9337adade2f5632/ml_dtypes-0.5.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad4953c5eb9c25a56d11a913c2011d7e580a435ef5145f804d98efa14477d390", size = 4694804, upload-time = "2025-01-07T03:34:23.608Z" }, + { url = "https://files.pythonhosted.org/packages/38/bc/c4260e4a6c6bf684d0313308de1c860467275221d5e7daf69b3fcddfdd0b/ml_dtypes-0.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:9626d0bca1fb387d5791ca36bacbba298c5ef554747b7ebeafefb4564fc83566", size = 210853, upload-time = "2025-01-07T03:34:26.027Z" }, + { url = "https://files.pythonhosted.org/packages/0f/92/bb6a3d18e16fddd18ce6d5f480e1919b33338c70e18cba831c6ae59812ee/ml_dtypes-0.5.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:12651420130ee7cc13059fc56dac6ad300c3af3848b802d475148c9defd27c23", size = 667696, upload-time = "2025-01-07T03:34:27.526Z" }, + { url = "https://files.pythonhosted.org/packages/6d/29/cfc89d842767e9a51146043b0fa18332c2b38f8831447e6cb1160e3c6102/ml_dtypes-0.5.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c9945669d3dadf8acb40ec2e57d38c985d8c285ea73af57fc5b09872c516106d", size = 4638365, upload-time = "2025-01-07T03:34:30.43Z" }, + { url = "https://files.pythonhosted.org/packages/be/26/adc36e3ea09603d9f6d114894e1c1b7b8e8a9ef6d0b031cc270c6624a37c/ml_dtypes-0.5.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bf9975bda82a99dc935f2ae4c83846d86df8fd6ba179614acac8e686910851da", size = 4702722, upload-time = "2025-01-07T03:34:33.813Z" }, + { url = "https://files.pythonhosted.org/packages/da/8a/a2b9375c94077e5a488a624a195621407846f504068ce22ccf805c674156/ml_dtypes-0.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:fd918d4e6a4e0c110e2e05be7a7814d10dc1b95872accbf6512b80a109b71ae1", size = 210850, upload-time = "2025-01-07T03:34:36.897Z" }, + { url = "https://files.pythonhosted.org/packages/52/38/703169100fdde27957f061d4d0ea3e00525775a09acaccf7e655d9609d55/ml_dtypes-0.5.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:05f23447a1c20ddf4dc7c2c661aa9ed93fcb2658f1017c204d1e758714dc28a8", size = 693043, upload-time = "2025-01-07T03:34:38.457Z" }, + { url = "https://files.pythonhosted.org/packages/28/ff/4e234c9c23e0d456f5da5a326c103bf890c746d93351524d987e41f438b3/ml_dtypes-0.5.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1b7fbe5571fdf28fd3aaab3ef4aafc847de9ebf263be959958c1ca58ec8eadf5", size = 4903946, upload-time = "2025-01-07T03:34:40.236Z" }, + { url = "https://files.pythonhosted.org/packages/b7/45/c1a1ccfdd02bc4173ca0f4a2d327683a27df85797b885eb1da1ca325b85c/ml_dtypes-0.5.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d13755f8e8445b3870114e5b6240facaa7cb0c3361e54beba3e07fa912a6e12b", size = 5052731, upload-time = "2025-01-07T03:34:45.308Z" }, +] + +[[package]] +name = "mpmath" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/47/dd32fa426cc72114383ac549964eecb20ecfd886d1e5ccf5340b55b02f57/mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f", size = 508106, upload-time = "2023-03-07T16:47:11.061Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198, upload-time = "2023-03-07T16:47:09.197Z" }, +] + +[[package]] +name = "narwhals" +version = "1.41.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/32/fc/7b9a3689911662be59889b1b0b40e17d5dba6f98080994d86ca1f3154d41/narwhals-1.41.0.tar.gz", hash = "sha256:0ab2e5a1757a19b071e37ca74b53b0b5426789321d68939738337dfddea629b5", size = 488446, upload-time = "2025-05-26T12:46:07.43Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c9/e0/ade8619846645461c012498f02b93a659e50f07d9d9a6ffefdf5ea2c02a0/narwhals-1.41.0-py3-none-any.whl", hash = "sha256:d958336b40952e4c4b7aeef259a7074851da0800cf902186a58f2faeff97be02", size = 357968, upload-time = "2025-05-26T12:46:05.207Z" }, +] + +[[package]] +name = "nest-asyncio" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/83/f8/51569ac65d696c8ecbee95938f89d4abf00f47d58d48f6fbabfe8f0baefe/nest_asyncio-1.6.0.tar.gz", hash = "sha256:6f172d5449aca15afd6c646851f4e31e02c598d553a667e38cafa997cfec55fe", size = 7418, upload-time = "2024-01-21T14:25:19.227Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/c4/c2971a3ba4c6103a3d10c4b0f24f461ddc027f0f09763220cf35ca1401b3/nest_asyncio-1.6.0-py3-none-any.whl", hash = "sha256:87af6efd6b5e897c81050477ef65c62e2b2f35d51703cae01aff2905b1852e1c", size = 5195, upload-time = "2024-01-21T14:25:17.223Z" }, +] + +[[package]] +name = "networkx" +version = "3.4.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fd/1d/06475e1cd5264c0b870ea2cc6fdb3e37177c1e565c43f56ff17a10e3937f/networkx-3.4.2.tar.gz", hash = "sha256:307c3669428c5362aab27c8a1260aa8f47c4e91d3891f48be0141738d8d053e1", size = 2151368, upload-time = "2024-10-21T12:39:38.695Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/54/dd730b32ea14ea797530a4479b2ed46a6fb250f682a9cfb997e968bf0261/networkx-3.4.2-py3-none-any.whl", hash = "sha256:df5d4365b724cf81b8c6a7312509d0c22386097011ad1abe274afd5e9d3bbc5f", size = 1723263, upload-time = "2024-10-21T12:39:36.247Z" }, +] + +[[package]] +name = "nodeenv" +version = "1.9.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437, upload-time = "2024-06-04T18:44:11.171Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314, upload-time = "2024-06-04T18:44:08.352Z" }, +] + +[[package]] +name = "numpy" +version = "2.2.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/76/21/7d2a95e4bba9dc13d043ee156a356c0a8f0c6309dff6b21b4d71a073b8a8/numpy-2.2.6.tar.gz", hash = "sha256:e29554e2bef54a90aa5cc07da6ce955accb83f21ab5de01a62c8478897b264fd", size = 20276440, upload-time = "2025-05-17T22:38:04.611Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3e/ed6db5be21ce87955c0cbd3009f2803f59fa08df21b5df06862e2d8e2bdd/numpy-2.2.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b412caa66f72040e6d268491a59f2c43bf03eb6c96dd8f0307829feb7fa2b6fb", size = 21165245, upload-time = "2025-05-17T21:27:58.555Z" }, + { url = "https://files.pythonhosted.org/packages/22/c2/4b9221495b2a132cc9d2eb862e21d42a009f5a60e45fc44b00118c174bff/numpy-2.2.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8e41fd67c52b86603a91c1a505ebaef50b3314de0213461c7a6e99c9a3beff90", size = 14360048, upload-time = "2025-05-17T21:28:21.406Z" }, + { url = "https://files.pythonhosted.org/packages/fd/77/dc2fcfc66943c6410e2bf598062f5959372735ffda175b39906d54f02349/numpy-2.2.6-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:37e990a01ae6ec7fe7fa1c26c55ecb672dd98b19c3d0e1d1f326fa13cb38d163", size = 5340542, upload-time = "2025-05-17T21:28:30.931Z" }, + { url = "https://files.pythonhosted.org/packages/7a/4f/1cb5fdc353a5f5cc7feb692db9b8ec2c3d6405453f982435efc52561df58/numpy-2.2.6-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:5a6429d4be8ca66d889b7cf70f536a397dc45ba6faeb5f8c5427935d9592e9cf", size = 6878301, upload-time = "2025-05-17T21:28:41.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/17/96a3acd228cec142fcb8723bd3cc39c2a474f7dcf0a5d16731980bcafa95/numpy-2.2.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:efd28d4e9cd7d7a8d39074a4d44c63eda73401580c5c76acda2ce969e0a38e83", size = 14297320, upload-time = "2025-05-17T21:29:02.78Z" }, + { url = "https://files.pythonhosted.org/packages/b4/63/3de6a34ad7ad6646ac7d2f55ebc6ad439dbbf9c4370017c50cf403fb19b5/numpy-2.2.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc7b73d02efb0e18c000e9ad8b83480dfcd5dfd11065997ed4c6747470ae8915", size = 16801050, upload-time = "2025-05-17T21:29:27.675Z" }, + { url = "https://files.pythonhosted.org/packages/07/b6/89d837eddef52b3d0cec5c6ba0456c1bf1b9ef6a6672fc2b7873c3ec4e2e/numpy-2.2.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:74d4531beb257d2c3f4b261bfb0fc09e0f9ebb8842d82a7b4209415896adc680", size = 15807034, upload-time = "2025-05-17T21:29:51.102Z" }, + { url = "https://files.pythonhosted.org/packages/01/c8/dc6ae86e3c61cfec1f178e5c9f7858584049b6093f843bca541f94120920/numpy-2.2.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8fc377d995680230e83241d8a96def29f204b5782f371c532579b4f20607a289", size = 18614185, upload-time = "2025-05-17T21:30:18.703Z" }, + { url = "https://files.pythonhosted.org/packages/5b/c5/0064b1b7e7c89137b471ccec1fd2282fceaae0ab3a9550f2568782d80357/numpy-2.2.6-cp310-cp310-win32.whl", hash = "sha256:b093dd74e50a8cba3e873868d9e93a85b78e0daf2e98c6797566ad8044e8363d", size = 6527149, upload-time = "2025-05-17T21:30:29.788Z" }, + { url = "https://files.pythonhosted.org/packages/a3/dd/4b822569d6b96c39d1215dbae0582fd99954dcbcf0c1a13c61783feaca3f/numpy-2.2.6-cp310-cp310-win_amd64.whl", hash = "sha256:f0fd6321b839904e15c46e0d257fdd101dd7f530fe03fd6359c1ea63738703f3", size = 12904620, upload-time = "2025-05-17T21:30:48.994Z" }, + { url = "https://files.pythonhosted.org/packages/da/a8/4f83e2aa666a9fbf56d6118faaaf5f1974d456b1823fda0a176eff722839/numpy-2.2.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f9f1adb22318e121c5c69a09142811a201ef17ab257a1e66ca3025065b7f53ae", size = 21176963, upload-time = "2025-05-17T21:31:19.36Z" }, + { url = "https://files.pythonhosted.org/packages/b3/2b/64e1affc7972decb74c9e29e5649fac940514910960ba25cd9af4488b66c/numpy-2.2.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c820a93b0255bc360f53eca31a0e676fd1101f673dda8da93454a12e23fc5f7a", size = 14406743, upload-time = "2025-05-17T21:31:41.087Z" }, + { url = "https://files.pythonhosted.org/packages/4a/9f/0121e375000b5e50ffdd8b25bf78d8e1a5aa4cca3f185d41265198c7b834/numpy-2.2.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:3d70692235e759f260c3d837193090014aebdf026dfd167834bcba43e30c2a42", size = 5352616, upload-time = "2025-05-17T21:31:50.072Z" }, + { url = "https://files.pythonhosted.org/packages/31/0d/b48c405c91693635fbe2dcd7bc84a33a602add5f63286e024d3b6741411c/numpy-2.2.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:481b49095335f8eed42e39e8041327c05b0f6f4780488f61286ed3c01368d491", size = 6889579, upload-time = "2025-05-17T21:32:01.712Z" }, + { url = "https://files.pythonhosted.org/packages/52/b8/7f0554d49b565d0171eab6e99001846882000883998e7b7d9f0d98b1f934/numpy-2.2.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b64d8d4d17135e00c8e346e0a738deb17e754230d7e0810ac5012750bbd85a5a", size = 14312005, upload-time = "2025-05-17T21:32:23.332Z" }, + { url = "https://files.pythonhosted.org/packages/b3/dd/2238b898e51bd6d389b7389ffb20d7f4c10066d80351187ec8e303a5a475/numpy-2.2.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba10f8411898fc418a521833e014a77d3ca01c15b0c6cdcce6a0d2897e6dbbdf", size = 16821570, upload-time = "2025-05-17T21:32:47.991Z" }, + { url = "https://files.pythonhosted.org/packages/83/6c/44d0325722cf644f191042bf47eedad61c1e6df2432ed65cbe28509d404e/numpy-2.2.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bd48227a919f1bafbdda0583705e547892342c26fb127219d60a5c36882609d1", size = 15818548, upload-time = "2025-05-17T21:33:11.728Z" }, + { url = "https://files.pythonhosted.org/packages/ae/9d/81e8216030ce66be25279098789b665d49ff19eef08bfa8cb96d4957f422/numpy-2.2.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9551a499bf125c1d4f9e250377c1ee2eddd02e01eac6644c080162c0c51778ab", size = 18620521, upload-time = "2025-05-17T21:33:39.139Z" }, + { url = "https://files.pythonhosted.org/packages/6a/fd/e19617b9530b031db51b0926eed5345ce8ddc669bb3bc0044b23e275ebe8/numpy-2.2.6-cp311-cp311-win32.whl", hash = "sha256:0678000bb9ac1475cd454c6b8c799206af8107e310843532b04d49649c717a47", size = 6525866, upload-time = "2025-05-17T21:33:50.273Z" }, + { url = "https://files.pythonhosted.org/packages/31/0a/f354fb7176b81747d870f7991dc763e157a934c717b67b58456bc63da3df/numpy-2.2.6-cp311-cp311-win_amd64.whl", hash = "sha256:e8213002e427c69c45a52bbd94163084025f533a55a59d6f9c5b820774ef3303", size = 12907455, upload-time = "2025-05-17T21:34:09.135Z" }, + { url = "https://files.pythonhosted.org/packages/82/5d/c00588b6cf18e1da539b45d3598d3557084990dcc4331960c15ee776ee41/numpy-2.2.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:41c5a21f4a04fa86436124d388f6ed60a9343a6f767fced1a8a71c3fbca038ff", size = 20875348, upload-time = "2025-05-17T21:34:39.648Z" }, + { url = "https://files.pythonhosted.org/packages/66/ee/560deadcdde6c2f90200450d5938f63a34b37e27ebff162810f716f6a230/numpy-2.2.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:de749064336d37e340f640b05f24e9e3dd678c57318c7289d222a8a2f543e90c", size = 14119362, upload-time = "2025-05-17T21:35:01.241Z" }, + { url = "https://files.pythonhosted.org/packages/3c/65/4baa99f1c53b30adf0acd9a5519078871ddde8d2339dc5a7fde80d9d87da/numpy-2.2.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:894b3a42502226a1cac872f840030665f33326fc3dac8e57c607905773cdcde3", size = 5084103, upload-time = "2025-05-17T21:35:10.622Z" }, + { url = "https://files.pythonhosted.org/packages/cc/89/e5a34c071a0570cc40c9a54eb472d113eea6d002e9ae12bb3a8407fb912e/numpy-2.2.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:71594f7c51a18e728451bb50cc60a3ce4e6538822731b2933209a1f3614e9282", size = 6625382, upload-time = "2025-05-17T21:35:21.414Z" }, + { url = "https://files.pythonhosted.org/packages/f8/35/8c80729f1ff76b3921d5c9487c7ac3de9b2a103b1cd05e905b3090513510/numpy-2.2.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2618db89be1b4e05f7a1a847a9c1c0abd63e63a1607d892dd54668dd92faf87", size = 14018462, upload-time = "2025-05-17T21:35:42.174Z" }, + { url = "https://files.pythonhosted.org/packages/8c/3d/1e1db36cfd41f895d266b103df00ca5b3cbe965184df824dec5c08c6b803/numpy-2.2.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd83c01228a688733f1ded5201c678f0c53ecc1006ffbc404db9f7a899ac6249", size = 16527618, upload-time = "2025-05-17T21:36:06.711Z" }, + { url = "https://files.pythonhosted.org/packages/61/c6/03ed30992602c85aa3cd95b9070a514f8b3c33e31124694438d88809ae36/numpy-2.2.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:37c0ca431f82cd5fa716eca9506aefcabc247fb27ba69c5062a6d3ade8cf8f49", size = 15505511, upload-time = "2025-05-17T21:36:29.965Z" }, + { url = "https://files.pythonhosted.org/packages/b7/25/5761d832a81df431e260719ec45de696414266613c9ee268394dd5ad8236/numpy-2.2.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fe27749d33bb772c80dcd84ae7e8df2adc920ae8297400dabec45f0dedb3f6de", size = 18313783, upload-time = "2025-05-17T21:36:56.883Z" }, + { url = "https://files.pythonhosted.org/packages/57/0a/72d5a3527c5ebffcd47bde9162c39fae1f90138c961e5296491ce778e682/numpy-2.2.6-cp312-cp312-win32.whl", hash = "sha256:4eeaae00d789f66c7a25ac5f34b71a7035bb474e679f410e5e1a94deb24cf2d4", size = 6246506, upload-time = "2025-05-17T21:37:07.368Z" }, + { url = "https://files.pythonhosted.org/packages/36/fa/8c9210162ca1b88529ab76b41ba02d433fd54fecaf6feb70ef9f124683f1/numpy-2.2.6-cp312-cp312-win_amd64.whl", hash = "sha256:c1f9540be57940698ed329904db803cf7a402f3fc200bfe599334c9bd84a40b2", size = 12614190, upload-time = "2025-05-17T21:37:26.213Z" }, + { url = "https://files.pythonhosted.org/packages/f9/5c/6657823f4f594f72b5471f1db1ab12e26e890bb2e41897522d134d2a3e81/numpy-2.2.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0811bb762109d9708cca4d0b13c4f67146e3c3b7cf8d34018c722adb2d957c84", size = 20867828, upload-time = "2025-05-17T21:37:56.699Z" }, + { url = "https://files.pythonhosted.org/packages/dc/9e/14520dc3dadf3c803473bd07e9b2bd1b69bc583cb2497b47000fed2fa92f/numpy-2.2.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:287cc3162b6f01463ccd86be154f284d0893d2b3ed7292439ea97eafa8170e0b", size = 14143006, upload-time = "2025-05-17T21:38:18.291Z" }, + { url = "https://files.pythonhosted.org/packages/4f/06/7e96c57d90bebdce9918412087fc22ca9851cceaf5567a45c1f404480e9e/numpy-2.2.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:f1372f041402e37e5e633e586f62aa53de2eac8d98cbfb822806ce4bbefcb74d", size = 5076765, upload-time = "2025-05-17T21:38:27.319Z" }, + { url = "https://files.pythonhosted.org/packages/73/ed/63d920c23b4289fdac96ddbdd6132e9427790977d5457cd132f18e76eae0/numpy-2.2.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:55a4d33fa519660d69614a9fad433be87e5252f4b03850642f88993f7b2ca566", size = 6617736, upload-time = "2025-05-17T21:38:38.141Z" }, + { url = "https://files.pythonhosted.org/packages/85/c5/e19c8f99d83fd377ec8c7e0cf627a8049746da54afc24ef0a0cb73d5dfb5/numpy-2.2.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f92729c95468a2f4f15e9bb94c432a9229d0d50de67304399627a943201baa2f", size = 14010719, upload-time = "2025-05-17T21:38:58.433Z" }, + { url = "https://files.pythonhosted.org/packages/19/49/4df9123aafa7b539317bf6d342cb6d227e49f7a35b99c287a6109b13dd93/numpy-2.2.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bc23a79bfabc5d056d106f9befb8d50c31ced2fbc70eedb8155aec74a45798f", size = 16526072, upload-time = "2025-05-17T21:39:22.638Z" }, + { url = "https://files.pythonhosted.org/packages/b2/6c/04b5f47f4f32f7c2b0e7260442a8cbcf8168b0e1a41ff1495da42f42a14f/numpy-2.2.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e3143e4451880bed956e706a3220b4e5cf6172ef05fcc397f6f36a550b1dd868", size = 15503213, upload-time = "2025-05-17T21:39:45.865Z" }, + { url = "https://files.pythonhosted.org/packages/17/0a/5cd92e352c1307640d5b6fec1b2ffb06cd0dabe7d7b8227f97933d378422/numpy-2.2.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b4f13750ce79751586ae2eb824ba7e1e8dba64784086c98cdbbcc6a42112ce0d", size = 18316632, upload-time = "2025-05-17T21:40:13.331Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3b/5cba2b1d88760ef86596ad0f3d484b1cbff7c115ae2429678465057c5155/numpy-2.2.6-cp313-cp313-win32.whl", hash = "sha256:5beb72339d9d4fa36522fc63802f469b13cdbe4fdab4a288f0c441b74272ebfd", size = 6244532, upload-time = "2025-05-17T21:43:46.099Z" }, + { url = "https://files.pythonhosted.org/packages/cb/3b/d58c12eafcb298d4e6d0d40216866ab15f59e55d148a5658bb3132311fcf/numpy-2.2.6-cp313-cp313-win_amd64.whl", hash = "sha256:b0544343a702fa80c95ad5d3d608ea3599dd54d4632df855e4c8d24eb6ecfa1c", size = 12610885, upload-time = "2025-05-17T21:44:05.145Z" }, + { url = "https://files.pythonhosted.org/packages/6b/9e/4bf918b818e516322db999ac25d00c75788ddfd2d2ade4fa66f1f38097e1/numpy-2.2.6-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0bca768cd85ae743b2affdc762d617eddf3bcf8724435498a1e80132d04879e6", size = 20963467, upload-time = "2025-05-17T21:40:44Z" }, + { url = "https://files.pythonhosted.org/packages/61/66/d2de6b291507517ff2e438e13ff7b1e2cdbdb7cb40b3ed475377aece69f9/numpy-2.2.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fc0c5673685c508a142ca65209b4e79ed6740a4ed6b2267dbba90f34b0b3cfda", size = 14225144, upload-time = "2025-05-17T21:41:05.695Z" }, + { url = "https://files.pythonhosted.org/packages/e4/25/480387655407ead912e28ba3a820bc69af9adf13bcbe40b299d454ec011f/numpy-2.2.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:5bd4fc3ac8926b3819797a7c0e2631eb889b4118a9898c84f585a54d475b7e40", size = 5200217, upload-time = "2025-05-17T21:41:15.903Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4a/6e313b5108f53dcbf3aca0c0f3e9c92f4c10ce57a0a721851f9785872895/numpy-2.2.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:fee4236c876c4e8369388054d02d0e9bb84821feb1a64dd59e137e6511a551f8", size = 6712014, upload-time = "2025-05-17T21:41:27.321Z" }, + { url = "https://files.pythonhosted.org/packages/b7/30/172c2d5c4be71fdf476e9de553443cf8e25feddbe185e0bd88b096915bcc/numpy-2.2.6-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e1dda9c7e08dc141e0247a5b8f49cf05984955246a327d4c48bda16821947b2f", size = 14077935, upload-time = "2025-05-17T21:41:49.738Z" }, + { url = "https://files.pythonhosted.org/packages/12/fb/9e743f8d4e4d3c710902cf87af3512082ae3d43b945d5d16563f26ec251d/numpy-2.2.6-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f447e6acb680fd307f40d3da4852208af94afdfab89cf850986c3ca00562f4fa", size = 16600122, upload-time = "2025-05-17T21:42:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/12/75/ee20da0e58d3a66f204f38916757e01e33a9737d0b22373b3eb5a27358f9/numpy-2.2.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:389d771b1623ec92636b0786bc4ae56abafad4a4c513d36a55dce14bd9ce8571", size = 15586143, upload-time = "2025-05-17T21:42:37.464Z" }, + { url = "https://files.pythonhosted.org/packages/76/95/bef5b37f29fc5e739947e9ce5179ad402875633308504a52d188302319c8/numpy-2.2.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8e9ace4a37db23421249ed236fdcdd457d671e25146786dfc96835cd951aa7c1", size = 18385260, upload-time = "2025-05-17T21:43:05.189Z" }, + { url = "https://files.pythonhosted.org/packages/09/04/f2f83279d287407cf36a7a8053a5abe7be3622a4363337338f2585e4afda/numpy-2.2.6-cp313-cp313t-win32.whl", hash = "sha256:038613e9fb8c72b0a41f025a7e4c3f0b7a1b5d768ece4796b674c8f3fe13efff", size = 6377225, upload-time = "2025-05-17T21:43:16.254Z" }, + { url = "https://files.pythonhosted.org/packages/67/0e/35082d13c09c02c011cf21570543d202ad929d961c02a147493cb0c2bdf5/numpy-2.2.6-cp313-cp313t-win_amd64.whl", hash = "sha256:6031dd6dfecc0cf9f668681a37648373bddd6421fff6c66ec1624eed0180ee06", size = 12771374, upload-time = "2025-05-17T21:43:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/9e/3b/d94a75f4dbf1ef5d321523ecac21ef23a3cd2ac8b78ae2aac40873590229/numpy-2.2.6-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0b605b275d7bd0c640cad4e5d30fa701a8d59302e127e5f79138ad62762c3e3d", size = 21040391, upload-time = "2025-05-17T21:44:35.948Z" }, + { url = "https://files.pythonhosted.org/packages/17/f4/09b2fa1b58f0fb4f7c7963a1649c64c4d315752240377ed74d9cd878f7b5/numpy-2.2.6-pp310-pypy310_pp73-macosx_14_0_x86_64.whl", hash = "sha256:7befc596a7dc9da8a337f79802ee8adb30a552a94f792b9c9d18c840055907db", size = 6786754, upload-time = "2025-05-17T21:44:47.446Z" }, + { url = "https://files.pythonhosted.org/packages/af/30/feba75f143bdc868a1cc3f44ccfa6c4b9ec522b36458e738cd00f67b573f/numpy-2.2.6-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce47521a4754c8f4593837384bd3424880629f718d87c5d44f8ed763edd63543", size = 16643476, upload-time = "2025-05-17T21:45:11.871Z" }, + { url = "https://files.pythonhosted.org/packages/37/48/ac2a9584402fb6c0cd5b5d1a91dcf176b15760130dd386bbafdbfe3640bf/numpy-2.2.6-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d042d24c90c41b54fd506da306759e06e568864df8ec17ccc17e9e884634fd00", size = 12812666, upload-time = "2025-05-17T21:45:31.426Z" }, +] + +[[package]] +name = "nvidia-cublas-cu12" +version = "12.6.4.1" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/af/eb/ff4b8c503fa1f1796679dce648854d58751982426e4e4b37d6fce49d259c/nvidia_cublas_cu12-12.6.4.1-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:08ed2686e9875d01b58e3cb379c6896df8e76c75e0d4a7f7dace3d7b6d9ef8eb", size = 393138322, upload-time = "2024-11-20T17:40:25.65Z" }, +] + +[[package]] +name = "nvidia-cuda-cupti-cu12" +version = "12.6.80" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/60/7b6497946d74bcf1de852a21824d63baad12cd417db4195fc1bfe59db953/nvidia_cuda_cupti_cu12-12.6.80-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6768bad6cab4f19e8292125e5f1ac8aa7d1718704012a0e3272a6f61c4bce132", size = 8917980, upload-time = "2024-11-20T17:36:04.019Z" }, + { url = "https://files.pythonhosted.org/packages/a5/24/120ee57b218d9952c379d1e026c4479c9ece9997a4fb46303611ee48f038/nvidia_cuda_cupti_cu12-12.6.80-py3-none-manylinux2014_x86_64.whl", hash = "sha256:a3eff6cdfcc6a4c35db968a06fcadb061cbc7d6dde548609a941ff8701b98b73", size = 8917972, upload-time = "2024-10-01T16:58:06.036Z" }, +] + +[[package]] +name = "nvidia-cuda-nvrtc-cu12" +version = "12.6.77" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/75/2e/46030320b5a80661e88039f59060d1790298b4718944a65a7f2aeda3d9e9/nvidia_cuda_nvrtc_cu12-12.6.77-py3-none-manylinux2014_x86_64.whl", hash = "sha256:35b0cc6ee3a9636d5409133e79273ce1f3fd087abb0532d2d2e8fff1fe9efc53", size = 23650380, upload-time = "2024-10-01T17:00:14.643Z" }, +] + +[[package]] +name = "nvidia-cuda-runtime-cu12" +version = "12.6.77" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/23/e717c5ac26d26cf39a27fbc076240fad2e3b817e5889d671b67f4f9f49c5/nvidia_cuda_runtime_cu12-12.6.77-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ba3b56a4f896141e25e19ab287cd71e52a6a0f4b29d0d31609f60e3b4d5219b7", size = 897690, upload-time = "2024-11-20T17:35:30.697Z" }, + { url = "https://files.pythonhosted.org/packages/f0/62/65c05e161eeddbafeca24dc461f47de550d9fa8a7e04eb213e32b55cfd99/nvidia_cuda_runtime_cu12-12.6.77-py3-none-manylinux2014_x86_64.whl", hash = "sha256:a84d15d5e1da416dd4774cb42edf5e954a3e60cc945698dc1d5be02321c44dc8", size = 897678, upload-time = "2024-10-01T16:57:33.821Z" }, +] + +[[package]] +name = "nvidia-cudnn-cu12" +version = "9.5.1.17" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-cublas-cu12", marker = "(platform_machine != 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux')" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/78/4535c9c7f859a64781e43c969a3a7e84c54634e319a996d43ef32ce46f83/nvidia_cudnn_cu12-9.5.1.17-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:30ac3869f6db17d170e0e556dd6cc5eee02647abc31ca856634d5a40f82c15b2", size = 570988386, upload-time = "2024-10-25T19:54:26.39Z" }, +] + +[[package]] +name = "nvidia-cufft-cu12" +version = "11.3.0.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-nvjitlink-cu12", marker = "(platform_machine != 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux')" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/16/73727675941ab8e6ffd86ca3a4b7b47065edcca7a997920b831f8147c99d/nvidia_cufft_cu12-11.3.0.4-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ccba62eb9cef5559abd5e0d54ceed2d9934030f51163df018532142a8ec533e5", size = 200221632, upload-time = "2024-11-20T17:41:32.357Z" }, + { url = "https://files.pythonhosted.org/packages/60/de/99ec247a07ea40c969d904fc14f3a356b3e2a704121675b75c366b694ee1/nvidia_cufft_cu12-11.3.0.4-py3-none-manylinux2014_x86_64.whl", hash = "sha256:768160ac89f6f7b459bee747e8d175dbf53619cfe74b2a5636264163138013ca", size = 200221622, upload-time = "2024-10-01T17:03:58.79Z" }, +] + +[[package]] +name = "nvidia-cufile-cu12" +version = "1.11.1.6" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/66/cc9876340ac68ae71b15c743ddb13f8b30d5244af344ec8322b449e35426/nvidia_cufile_cu12-1.11.1.6-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc23469d1c7e52ce6c1d55253273d32c565dd22068647f3aa59b3c6b005bf159", size = 1142103, upload-time = "2024-11-20T17:42:11.83Z" }, +] + +[[package]] +name = "nvidia-curand-cu12" +version = "10.3.7.77" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/1b/44a01c4e70933637c93e6e1a8063d1e998b50213a6b65ac5a9169c47e98e/nvidia_curand_cu12-10.3.7.77-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a42cd1344297f70b9e39a1e4f467a4e1c10f1da54ff7a85c12197f6c652c8bdf", size = 56279010, upload-time = "2024-11-20T17:42:50.958Z" }, + { url = "https://files.pythonhosted.org/packages/4a/aa/2c7ff0b5ee02eaef890c0ce7d4f74bc30901871c5e45dee1ae6d0083cd80/nvidia_curand_cu12-10.3.7.77-py3-none-manylinux2014_x86_64.whl", hash = "sha256:99f1a32f1ac2bd134897fc7a203f779303261268a65762a623bf30cc9fe79117", size = 56279000, upload-time = "2024-10-01T17:04:45.274Z" }, +] + +[[package]] +name = "nvidia-cusolver-cu12" +version = "11.7.1.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-cublas-cu12", marker = "(platform_machine != 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux')" }, + { name = "nvidia-cusparse-cu12", marker = "(platform_machine != 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux')" }, + { name = "nvidia-nvjitlink-cu12", marker = "(platform_machine != 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux')" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/f0/6e/c2cf12c9ff8b872e92b4a5740701e51ff17689c4d726fca91875b07f655d/nvidia_cusolver_cu12-11.7.1.2-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e9e49843a7707e42022babb9bcfa33c29857a93b88020c4e4434656a655b698c", size = 158229790, upload-time = "2024-11-20T17:43:43.211Z" }, + { url = "https://files.pythonhosted.org/packages/9f/81/baba53585da791d043c10084cf9553e074548408e04ae884cfe9193bd484/nvidia_cusolver_cu12-11.7.1.2-py3-none-manylinux2014_x86_64.whl", hash = "sha256:6cf28f17f64107a0c4d7802be5ff5537b2130bfc112f25d5a30df227058ca0e6", size = 158229780, upload-time = "2024-10-01T17:05:39.875Z" }, +] + +[[package]] +name = "nvidia-cusparse-cu12" +version = "12.5.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-nvjitlink-cu12", marker = "(platform_machine != 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux')" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/1e/b8b7c2f4099a37b96af5c9bb158632ea9e5d9d27d7391d7eb8fc45236674/nvidia_cusparse_cu12-12.5.4.2-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7556d9eca156e18184b94947ade0fba5bb47d69cec46bf8660fd2c71a4b48b73", size = 216561367, upload-time = "2024-11-20T17:44:54.824Z" }, + { url = "https://files.pythonhosted.org/packages/43/ac/64c4316ba163e8217a99680c7605f779accffc6a4bcd0c778c12948d3707/nvidia_cusparse_cu12-12.5.4.2-py3-none-manylinux2014_x86_64.whl", hash = "sha256:23749a6571191a215cb74d1cdbff4a86e7b19f1200c071b3fcf844a5bea23a2f", size = 216561357, upload-time = "2024-10-01T17:06:29.861Z" }, +] + +[[package]] +name = "nvidia-cusparselt-cu12" +version = "0.6.3" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/9a/72ef35b399b0e183bc2e8f6f558036922d453c4d8237dab26c666a04244b/nvidia_cusparselt_cu12-0.6.3-py3-none-manylinux2014_x86_64.whl", hash = "sha256:e5c8a26c36445dd2e6812f1177978a24e2d37cacce7e090f297a688d1ec44f46", size = 156785796, upload-time = "2024-10-15T21:29:17.709Z" }, +] + +[[package]] +name = "nvidia-nccl-cu12" +version = "2.26.2" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/ca/f42388aed0fddd64ade7493dbba36e1f534d4e6fdbdd355c6a90030ae028/nvidia_nccl_cu12-2.26.2-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:694cf3879a206553cc9d7dbda76b13efaf610fdb70a50cba303de1b0d1530ac6", size = 201319755, upload-time = "2025-03-13T00:29:55.296Z" }, +] + +[[package]] +name = "nvidia-nvjitlink-cu12" +version = "12.6.85" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/d7/c5383e47c7e9bf1c99d5bd2a8c935af2b6d705ad831a7ec5c97db4d82f4f/nvidia_nvjitlink_cu12-12.6.85-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:eedc36df9e88b682efe4309aa16b5b4e78c2407eac59e8c10a6a47535164369a", size = 19744971, upload-time = "2024-11-20T17:46:53.366Z" }, +] + +[[package]] +name = "nvidia-nvtx-cu12" +version = "12.6.77" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/9a/fff8376f8e3d084cd1530e1ef7b879bb7d6d265620c95c1b322725c694f4/nvidia_nvtx_cu12-12.6.77-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b90bed3df379fa79afbd21be8e04a0314336b8ae16768b58f2d34cb1d04cd7d2", size = 89276, upload-time = "2024-11-20T17:38:27.621Z" }, + { url = "https://files.pythonhosted.org/packages/9e/4e/0d0c945463719429b7bd21dece907ad0bde437a2ff12b9b12fee94722ab0/nvidia_nvtx_cu12-12.6.77-py3-none-manylinux2014_x86_64.whl", hash = "sha256:6574241a3ec5fdc9334353ab8c479fe75841dbe8f4532a8fc97ce63503330ba1", size = 89265, upload-time = "2024-10-01T17:00:38.172Z" }, +] + +[[package]] +name = "onnx" +version = "1.18.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "protobuf" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/60/e56e8ec44ed34006e6d4a73c92a04d9eea6163cc12440e35045aec069175/onnx-1.18.0.tar.gz", hash = "sha256:3d8dbf9e996629131ba3aa1afd1d8239b660d1f830c6688dd7e03157cccd6b9c", size = 12563009, upload-time = "2025-05-12T22:03:09.626Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/e3/ab8a09c0af43373e0422de461956a1737581325260659aeffae22a7dad18/onnx-1.18.0-cp310-cp310-macosx_12_0_universal2.whl", hash = "sha256:4a3b50d94620e2c7c1404d1d59bc53e665883ae3fecbd856cc86da0639fd0fc3", size = 18280145, upload-time = "2025-05-12T22:01:49.875Z" }, + { url = "https://files.pythonhosted.org/packages/04/5b/3cfd183961a0a872fe29c95f8d07264890ec65c75c94b99a4dabc950df29/onnx-1.18.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e189652dad6e70a0465035c55cc565c27aa38803dd4f4e74e4b952ee1c2de94b", size = 17422721, upload-time = "2025-05-12T22:01:52.841Z" }, + { url = "https://files.pythonhosted.org/packages/58/52/fa649429016c5790f68c614cdebfbefd3e72ba1c458966305297d540f713/onnx-1.18.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bfb1f271b1523b29f324bfd223f6a4cfbdc5a2f2f16e73563671932d33663365", size = 17584220, upload-time = "2025-05-12T22:01:56.458Z" }, + { url = "https://files.pythonhosted.org/packages/42/52/dc166de41a5f72738b0bdfb2a19e0ebe4743cf3ecc9ae381ea3425bcb332/onnx-1.18.0-cp310-cp310-win32.whl", hash = "sha256:e03071041efd82e0317b3c45433b2f28146385b80f26f82039bc68048ac1a7a0", size = 15734494, upload-time = "2025-05-12T22:01:59.704Z" }, + { url = "https://files.pythonhosted.org/packages/a6/f9/e766a3b85b7651ddfc5f9648e0e9dc24e88b7e88ea7f8c23187530e818ea/onnx-1.18.0-cp310-cp310-win_amd64.whl", hash = "sha256:9235b3493951e11e75465d56f4cd97e3e9247f096160dd3466bfabe4cbc938bc", size = 15848421, upload-time = "2025-05-12T22:02:03.01Z" }, + { url = "https://files.pythonhosted.org/packages/ed/3a/a336dac4db1eddba2bf577191e5b7d3e4c26fcee5ec518a5a5b11d13540d/onnx-1.18.0-cp311-cp311-macosx_12_0_universal2.whl", hash = "sha256:735e06d8d0cf250dc498f54038831401063c655a8d6e5975b2527a4e7d24be3e", size = 18281831, upload-time = "2025-05-12T22:02:06.429Z" }, + { url = "https://files.pythonhosted.org/packages/02/3a/56475a111120d1e5d11939acbcbb17c92198c8e64a205cd68e00bdfd8a1f/onnx-1.18.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:73160799472e1a86083f786fecdf864cf43d55325492a9b5a1cfa64d8a523ecc", size = 17424359, upload-time = "2025-05-12T22:02:09.866Z" }, + { url = "https://files.pythonhosted.org/packages/cf/03/5eb5e9ef446ed9e78c4627faf3c1bc25e0f707116dd00e9811de232a8df5/onnx-1.18.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6acafb3823238bbe8f4340c7ac32fb218689442e074d797bee1c5c9a02fdae75", size = 17586006, upload-time = "2025-05-12T22:02:13.217Z" }, + { url = "https://files.pythonhosted.org/packages/b0/4e/70943125729ce453271a6e46bb847b4a612496f64db6cbc6cb1f49f41ce1/onnx-1.18.0-cp311-cp311-win32.whl", hash = "sha256:4c8c4bbda760c654e65eaffddb1a7de71ec02e60092d33f9000521f897c99be9", size = 15734988, upload-time = "2025-05-12T22:02:16.561Z" }, + { url = "https://files.pythonhosted.org/packages/44/b0/435fd764011911e8f599e3361f0f33425b1004662c1ea33a0ad22e43db2d/onnx-1.18.0-cp311-cp311-win_amd64.whl", hash = "sha256:a5810194f0f6be2e58c8d6dedc6119510df7a14280dd07ed5f0f0a85bd74816a", size = 15849576, upload-time = "2025-05-12T22:02:19.569Z" }, + { url = "https://files.pythonhosted.org/packages/6c/f0/9e31f4b4626d60f1c034f71b411810bc9fafe31f4e7dd3598effd1b50e05/onnx-1.18.0-cp311-cp311-win_arm64.whl", hash = "sha256:aa1b7483fac6cdec26922174fc4433f8f5c2f239b1133c5625063bb3b35957d0", size = 15822961, upload-time = "2025-05-12T22:02:22.735Z" }, + { url = "https://files.pythonhosted.org/packages/a7/fe/16228aca685392a7114625b89aae98b2dc4058a47f0f467a376745efe8d0/onnx-1.18.0-cp312-cp312-macosx_12_0_universal2.whl", hash = "sha256:521bac578448667cbb37c50bf05b53c301243ede8233029555239930996a625b", size = 18285770, upload-time = "2025-05-12T22:02:26.116Z" }, + { url = "https://files.pythonhosted.org/packages/1e/77/ba50a903a9b5e6f9be0fa50f59eb2fca4a26ee653375408fbc72c3acbf9f/onnx-1.18.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e4da451bf1c5ae381f32d430004a89f0405bc57a8471b0bddb6325a5b334aa40", size = 17421291, upload-time = "2025-05-12T22:02:29.645Z" }, + { url = "https://files.pythonhosted.org/packages/11/23/25ec2ba723ac62b99e8fed6d7b59094dadb15e38d4c007331cc9ae3dfa5f/onnx-1.18.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:99afac90b4cdb1471432203c3c1f74e16549c526df27056d39f41a9a47cfb4af", size = 17584084, upload-time = "2025-05-12T22:02:32.789Z" }, + { url = "https://files.pythonhosted.org/packages/6a/4d/2c253a36070fb43f340ff1d2c450df6a9ef50b938adcd105693fee43c4ee/onnx-1.18.0-cp312-cp312-win32.whl", hash = "sha256:ee159b41a3ae58d9c7341cf432fc74b96aaf50bd7bb1160029f657b40dc69715", size = 15734892, upload-time = "2025-05-12T22:02:35.527Z" }, + { url = "https://files.pythonhosted.org/packages/e8/92/048ba8fafe6b2b9a268ec2fb80def7e66c0b32ab2cae74de886981f05a27/onnx-1.18.0-cp312-cp312-win_amd64.whl", hash = "sha256:102c04edc76b16e9dfeda5a64c1fccd7d3d2913b1544750c01d38f1ac3c04e05", size = 15850336, upload-time = "2025-05-12T22:02:38.545Z" }, + { url = "https://files.pythonhosted.org/packages/a1/66/bbc4ffedd44165dcc407a51ea4c592802a5391ce3dc94aa5045350f64635/onnx-1.18.0-cp312-cp312-win_arm64.whl", hash = "sha256:911b37d724a5d97396f3c2ef9ea25361c55cbc9aa18d75b12a52b620b67145af", size = 15823802, upload-time = "2025-05-12T22:02:42.037Z" }, + { url = "https://files.pythonhosted.org/packages/45/da/9fb8824513fae836239276870bfcc433fa2298d34ed282c3a47d3962561b/onnx-1.18.0-cp313-cp313-macosx_12_0_universal2.whl", hash = "sha256:030d9f5f878c5f4c0ff70a4545b90d7812cd6bfe511de2f3e469d3669c8cff95", size = 18285906, upload-time = "2025-05-12T22:02:45.01Z" }, + { url = "https://files.pythonhosted.org/packages/05/e8/762b5fb5ed1a2b8e9a4bc5e668c82723b1b789c23b74e6b5a3356731ae4e/onnx-1.18.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8521544987d713941ee1e591520044d35e702f73dc87e91e6d4b15a064ae813d", size = 17421486, upload-time = "2025-05-12T22:02:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/12/bb/471da68df0364f22296456c7f6becebe0a3da1ba435cdb371099f516da6e/onnx-1.18.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3c137eecf6bc618c2f9398bcc381474b55c817237992b169dfe728e169549e8f", size = 17583581, upload-time = "2025-05-12T22:02:51.784Z" }, + { url = "https://files.pythonhosted.org/packages/76/0d/01a95edc2cef6ad916e04e8e1267a9286f15b55c90cce5d3cdeb359d75d6/onnx-1.18.0-cp313-cp313-win32.whl", hash = "sha256:6c093ffc593e07f7e33862824eab9225f86aa189c048dd43ffde207d7041a55f", size = 15734621, upload-time = "2025-05-12T22:02:54.62Z" }, + { url = "https://files.pythonhosted.org/packages/64/95/253451a751be32b6173a648b68f407188009afa45cd6388780c330ff5d5d/onnx-1.18.0-cp313-cp313-win_amd64.whl", hash = "sha256:230b0fb615e5b798dc4a3718999ec1828360bc71274abd14f915135eab0255f1", size = 15850472, upload-time = "2025-05-12T22:02:57.54Z" }, + { url = "https://files.pythonhosted.org/packages/0a/b1/6fd41b026836df480a21687076e0f559bc3ceeac90f2be8c64b4a7a1f332/onnx-1.18.0-cp313-cp313-win_arm64.whl", hash = "sha256:6f91930c1a284135db0f891695a263fc876466bf2afbd2215834ac08f600cfca", size = 15823808, upload-time = "2025-05-12T22:03:00.305Z" }, + { url = "https://files.pythonhosted.org/packages/70/f3/499e53dd41fa7302f914dd18543da01e0786a58b9a9d347497231192001f/onnx-1.18.0-cp313-cp313t-macosx_12_0_universal2.whl", hash = "sha256:2f4d37b0b5c96a873887652d1cbf3f3c70821b8c66302d84b0f0d89dd6e47653", size = 18316526, upload-time = "2025-05-12T22:03:03.691Z" }, + { url = "https://files.pythonhosted.org/packages/84/dd/6abe5d7bd23f5ed3ade8352abf30dff1c7a9e97fc1b0a17b5d7c726e98a9/onnx-1.18.0-cp313-cp313t-win_amd64.whl", hash = "sha256:a69afd0baa372162948b52c13f3aa2730123381edf926d7ef3f68ca7cec6d0d0", size = 15865055, upload-time = "2025-05-12T22:03:06.663Z" }, +] + +[[package]] +name = "onnxruntime" +version = "1.22.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coloredlogs" }, + { name = "flatbuffers" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "protobuf" }, + { name = "sympy" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/3c/c99b21646a782b89c33cffd96fdee02a81bc43f0cb651de84d58ec11e30e/onnxruntime-1.22.0-cp310-cp310-macosx_13_0_universal2.whl", hash = "sha256:85d8826cc8054e4d6bf07f779dc742a363c39094015bdad6a08b3c18cfe0ba8c", size = 34273493, upload-time = "2025-05-09T20:25:55.66Z" }, + { url = "https://files.pythonhosted.org/packages/54/ab/fd9a3b5285008c060618be92e475337fcfbf8689787953d37273f7b52ab0/onnxruntime-1.22.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:468c9502a12f6f49ec335c2febd22fdceecc1e4cc96dfc27e419ba237dff5aff", size = 14445346, upload-time = "2025-05-09T20:25:41.322Z" }, + { url = "https://files.pythonhosted.org/packages/1f/ca/a5625644bc079e04e3076a5ac1fb954d1e90309b8eb987a4f800732ffee6/onnxruntime-1.22.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:681fe356d853630a898ee05f01ddb95728c9a168c9460e8361d0a240c9b7cb97", size = 16392959, upload-time = "2025-05-09T20:26:09.047Z" }, + { url = "https://files.pythonhosted.org/packages/6d/6b/8267490476e8d4dd1883632c7e46a4634384c7ff1c35ae44edc8ab0bb7a9/onnxruntime-1.22.0-cp310-cp310-win_amd64.whl", hash = "sha256:20bca6495d06925631e201f2b257cc37086752e8fe7b6c83a67c6509f4759bc9", size = 12689974, upload-time = "2025-05-12T21:26:09.704Z" }, + { url = "https://files.pythonhosted.org/packages/7a/08/c008711d1b92ff1272f4fea0fbee57723171f161d42e5c680625535280af/onnxruntime-1.22.0-cp311-cp311-macosx_13_0_universal2.whl", hash = "sha256:8d6725c5b9a681d8fe72f2960c191a96c256367887d076b08466f52b4e0991df", size = 34282151, upload-time = "2025-05-09T20:25:59.246Z" }, + { url = "https://files.pythonhosted.org/packages/3e/8b/22989f6b59bc4ad1324f07a945c80b9ab825f0a581ad7a6064b93716d9b7/onnxruntime-1.22.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fef17d665a917866d1f68f09edc98223b9a27e6cb167dec69da4c66484ad12fd", size = 14446302, upload-time = "2025-05-09T20:25:44.299Z" }, + { url = "https://files.pythonhosted.org/packages/7a/d5/aa83d084d05bc8f6cf8b74b499c77431ffd6b7075c761ec48ec0c161a47f/onnxruntime-1.22.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b978aa63a9a22095479c38371a9b359d4c15173cbb164eaad5f2cd27d666aa65", size = 16393496, upload-time = "2025-05-09T20:26:11.588Z" }, + { url = "https://files.pythonhosted.org/packages/89/a5/1c6c10322201566015183b52ef011dfa932f5dd1b278de8d75c3b948411d/onnxruntime-1.22.0-cp311-cp311-win_amd64.whl", hash = "sha256:03d3ef7fb11adf154149d6e767e21057e0e577b947dd3f66190b212528e1db31", size = 12691517, upload-time = "2025-05-12T21:26:13.354Z" }, + { url = "https://files.pythonhosted.org/packages/4d/de/9162872c6e502e9ac8c99a98a8738b2fab408123d11de55022ac4f92562a/onnxruntime-1.22.0-cp312-cp312-macosx_13_0_universal2.whl", hash = "sha256:f3c0380f53c1e72a41b3f4d6af2ccc01df2c17844072233442c3a7e74851ab97", size = 34298046, upload-time = "2025-05-09T20:26:02.399Z" }, + { url = "https://files.pythonhosted.org/packages/03/79/36f910cd9fc96b444b0e728bba14607016079786adf032dae61f7c63b4aa/onnxruntime-1.22.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8601128eaef79b636152aea76ae6981b7c9fc81a618f584c15d78d42b310f1c", size = 14443220, upload-time = "2025-05-09T20:25:47.078Z" }, + { url = "https://files.pythonhosted.org/packages/8c/60/16d219b8868cc8e8e51a68519873bdb9f5f24af080b62e917a13fff9989b/onnxruntime-1.22.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6964a975731afc19dc3418fad8d4e08c48920144ff590149429a5ebe0d15fb3c", size = 16406377, upload-time = "2025-05-09T20:26:14.478Z" }, + { url = "https://files.pythonhosted.org/packages/36/b4/3f1c71ce1d3d21078a6a74c5483bfa2b07e41a8d2b8fb1e9993e6a26d8d3/onnxruntime-1.22.0-cp312-cp312-win_amd64.whl", hash = "sha256:c0d534a43d1264d1273c2d4f00a5a588fa98d21117a3345b7104fa0bbcaadb9a", size = 12692233, upload-time = "2025-05-12T21:26:16.963Z" }, + { url = "https://files.pythonhosted.org/packages/a9/65/5cb5018d5b0b7cba820d2c4a1d1b02d40df538d49138ba36a509457e4df6/onnxruntime-1.22.0-cp313-cp313-macosx_13_0_universal2.whl", hash = "sha256:fe7c051236aae16d8e2e9ffbfc1e115a0cc2450e873a9c4cb75c0cc96c1dae07", size = 34298715, upload-time = "2025-05-09T20:26:05.634Z" }, + { url = "https://files.pythonhosted.org/packages/e1/89/1dfe1b368831d1256b90b95cb8d11da8ab769febd5c8833ec85ec1f79d21/onnxruntime-1.22.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6a6bbed10bc5e770c04d422893d3045b81acbbadc9fb759a2cd1ca00993da919", size = 14443266, upload-time = "2025-05-09T20:25:49.479Z" }, + { url = "https://files.pythonhosted.org/packages/1e/70/342514ade3a33ad9dd505dcee96ff1f0e7be6d0e6e9c911fe0f1505abf42/onnxruntime-1.22.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9fe45ee3e756300fccfd8d61b91129a121d3d80e9d38e01f03ff1295badc32b8", size = 16406707, upload-time = "2025-05-09T20:26:17.454Z" }, + { url = "https://files.pythonhosted.org/packages/3e/89/2f64e250945fa87140fb917ba377d6d0e9122e029c8512f389a9b7f953f4/onnxruntime-1.22.0-cp313-cp313-win_amd64.whl", hash = "sha256:5a31d84ef82b4b05d794a4ce8ba37b0d9deb768fd580e36e17b39e0b4840253b", size = 12691777, upload-time = "2025-05-12T21:26:20.19Z" }, + { url = "https://files.pythonhosted.org/packages/9f/48/d61d5f1ed098161edd88c56cbac49207d7b7b149e613d2cd7e33176c63b3/onnxruntime-1.22.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a2ac5bd9205d831541db4e508e586e764a74f14efdd3f89af7fd20e1bf4a1ed", size = 14454003, upload-time = "2025-05-09T20:25:52.287Z" }, + { url = "https://files.pythonhosted.org/packages/c3/16/873b955beda7bada5b0d798d3a601b2ff210e44ad5169f6d405b93892103/onnxruntime-1.22.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:64845709f9e8a2809e8e009bc4c8f73b788cee9c6619b7d9930344eae4c9cd36", size = 16427482, upload-time = "2025-05-09T20:26:20.376Z" }, +] + +[[package]] +name = "onnxruntime-gpu" +version = "1.22.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coloredlogs" }, + { name = "flatbuffers" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "protobuf" }, + { name = "sympy" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/76/81de592072d6a41553b1523e15447f0ef94392e8f4cb98fda42909f24f9b/onnxruntime_gpu-1.22.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:965da7d33a54917e8e5176f292cc22640819f328370f4fb86087908745b03708", size = 283205327, upload-time = "2025-05-09T19:39:24.231Z" }, + { url = "https://files.pythonhosted.org/packages/74/7b/636cb1e19cf1340e4eaf0da6a4cc10cf2ae56f00693b4ff61c28dd0c7160/onnxruntime_gpu-1.22.0-cp310-cp310-win_amd64.whl", hash = "sha256:6db51c375ffe3887fe5cce61a0ae054e5e9c1eaf0603f8a106589a819976e4b2", size = 214923182, upload-time = "2025-05-09T19:32:35.985Z" }, + { url = "https://files.pythonhosted.org/packages/4a/10/cd3e7e289f7b46eb93e38b5c90139f735bf1ea7f03d4b17ceb0e998e5bb6/onnxruntime_gpu-1.22.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d30c1512f22b1f01bacb4f177d49cbefd23e0f4bef56066f1282992d133e6ff8", size = 283204403, upload-time = "2025-05-09T19:39:38.278Z" }, + { url = "https://files.pythonhosted.org/packages/1e/47/313ee7998ef63dd7533200966972056fc5f3c7dd3bdfd9c49ae833bb5108/onnxruntime_gpu-1.22.0-cp311-cp311-win_amd64.whl", hash = "sha256:0f1719f7cca76075b398a7d0466ead62d78fd2b8c0ea053dcf65d80c813103e8", size = 214923507, upload-time = "2025-05-09T19:32:51.275Z" }, + { url = "https://files.pythonhosted.org/packages/b5/5c/3f9700ba277d52c121dd2cebc8a672fb60b53e888972fc6682b6692a766c/onnxruntime_gpu-1.22.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:86b064c8f6cbe6da03f51f46351237d985f8fd5eb907d3f9997ea91881131a13", size = 283199528, upload-time = "2025-05-09T19:39:54.489Z" }, + { url = "https://files.pythonhosted.org/packages/48/9e/f95af15627c8b9f866f2e372e467a9f1e14e7ebec224ed4b8e71ce970c81/onnxruntime_gpu-1.22.0-cp312-cp312-win_amd64.whl", hash = "sha256:89cfd71e1ba17a4668e8770e344f22cde64bfd70b2ad3d03b8a390d4414b5995", size = 214923964, upload-time = "2025-05-09T19:33:04.028Z" }, + { url = "https://files.pythonhosted.org/packages/ae/26/35efe9dae012f453f2f7698dec3604368ce91ee2a0464336d2284fe02e3b/onnxruntime_gpu-1.22.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c3e635792931c5edf48a6a44b8daf4f74a9458e2d60245d24d91e29b6c1c7aa5", size = 283205630, upload-time = "2025-05-09T19:40:12.749Z" }, + { url = "https://files.pythonhosted.org/packages/7f/d8/0063e4973c54d3b39d6b3025a31f80bfda6386fa0eb16fc047f2fe724832/onnxruntime_gpu-1.22.0-cp313-cp313-win_amd64.whl", hash = "sha256:082c9744b0470448a7d814babe058d0b5074380f32839aa655e5e5f9975f6d94", size = 214924126, upload-time = "2025-05-09T19:33:14.647Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ab/943c659cded9288519c67e6d5827973762207d19035972c703a1fefd032c/onnxruntime_gpu-1.22.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d1559033601d71023d72a8e279b2575a104de5f46e136f87534206aa2044eb1c", size = 283210584, upload-time = "2025-05-09T19:40:27.372Z" }, +] + +[[package]] +name = "onnxscript" +version = "0.2.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ml-dtypes" }, + { name = "numpy" }, + { name = "onnx" }, + { name = "packaging" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c4/c0/ca5afd062c54061fa4ebbdfee8eaf5879bd2095fbb9babb6eda3abc2cfbe/onnxscript-0.2.7.tar.gz", hash = "sha256:669cd60ea3466d5b1443d3db38753da45acba6b002de64441713a5e782728f03", size = 637958, upload-time = "2025-05-28T01:05:05.513Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/f7/ef7ff99530a9f23d7a2725b744bbe03a47006649224de1e08f9d71267ec8/onnxscript-0.2.7-py3-none-any.whl", hash = "sha256:d4bb7d1e7c7aebce35400db1dba4e37b57b9ac80340db1fe627bdd0b9730d1e4", size = 735802, upload-time = "2025-05-28T01:05:08.201Z" }, +] + +[[package]] +name = "onnxslim" +version = "0.1.54" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "onnx" }, + { name = "packaging" }, + { name = "sympy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/35/ce/ef568367bd32643da7c73245fe4c7970c080b51158c6e50d042a72c18a3a/onnxslim-0.1.54.tar.gz", hash = "sha256:5c385c3a99945cd79fc8d07b96e1777882bc8fd2375ee37376b2a554f53717f3", size = 123683, upload-time = "2025-05-29T01:13:56.913Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/bc/00484118e6a11851f6737ad957aaf60e99fb60875754316f3d8c37967482/onnxslim-0.1.54-py3-none-any.whl", hash = "sha256:5590a78b8ed3ae120339f3d6499696a4cc27e30a84bc098cef1829d668627e6d", size = 146205, upload-time = "2025-05-29T01:13:55.258Z" }, +] + +[[package]] +name = "opencv-python" +version = "4.11.0.86" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/17/06/68c27a523103dad5837dc5b87e71285280c4f098c60e4fe8a8db6486ab09/opencv-python-4.11.0.86.tar.gz", hash = "sha256:03d60ccae62304860d232272e4a4fda93c39d595780cb40b161b310244b736a4", size = 95171956, upload-time = "2025-01-16T13:52:24.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/4d/53b30a2a3ac1f75f65a59eb29cf2ee7207ce64867db47036ad61743d5a23/opencv_python-4.11.0.86-cp37-abi3-macosx_13_0_arm64.whl", hash = "sha256:432f67c223f1dc2824f5e73cdfcd9db0efc8710647d4e813012195dc9122a52a", size = 37326322, upload-time = "2025-01-16T13:52:25.887Z" }, + { url = "https://files.pythonhosted.org/packages/3b/84/0a67490741867eacdfa37bc18df96e08a9d579583b419010d7f3da8ff503/opencv_python-4.11.0.86-cp37-abi3-macosx_13_0_x86_64.whl", hash = "sha256:9d05ef13d23fe97f575153558653e2d6e87103995d54e6a35db3f282fe1f9c66", size = 56723197, upload-time = "2025-01-16T13:55:21.222Z" }, + { url = "https://files.pythonhosted.org/packages/f3/bd/29c126788da65c1fb2b5fb621b7fed0ed5f9122aa22a0868c5e2c15c6d23/opencv_python-4.11.0.86-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1b92ae2c8852208817e6776ba1ea0d6b1e0a1b5431e971a2a0ddd2a8cc398202", size = 42230439, upload-time = "2025-01-16T13:51:35.822Z" }, + { url = "https://files.pythonhosted.org/packages/2c/8b/90eb44a40476fa0e71e05a0283947cfd74a5d36121a11d926ad6f3193cc4/opencv_python-4.11.0.86-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6b02611523803495003bd87362db3e1d2a0454a6a63025dc6658a9830570aa0d", size = 62986597, upload-time = "2025-01-16T13:52:08.836Z" }, + { url = "https://files.pythonhosted.org/packages/fb/d7/1d5941a9dde095468b288d989ff6539dd69cd429dbf1b9e839013d21b6f0/opencv_python-4.11.0.86-cp37-abi3-win32.whl", hash = "sha256:810549cb2a4aedaa84ad9a1c92fbfdfc14090e2749cedf2c1589ad8359aa169b", size = 29384337, upload-time = "2025-01-16T13:52:13.549Z" }, + { url = "https://files.pythonhosted.org/packages/a4/7d/f1c30a92854540bf789e9cd5dde7ef49bbe63f855b85a2e6b3db8135c591/opencv_python-4.11.0.86-cp37-abi3-win_amd64.whl", hash = "sha256:085ad9b77c18853ea66283e98affefe2de8cc4c1f43eda4c100cf9b2721142ec", size = 39488044, upload-time = "2025-01-16T13:52:21.928Z" }, +] + +[[package]] +name = "orjson" +version = "3.10.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/81/0b/fea456a3ffe74e70ba30e01ec183a9b26bec4d497f61dcfce1b601059c60/orjson-3.10.18.tar.gz", hash = "sha256:e8da3947d92123eda795b68228cafe2724815621fe35e8e320a9e9593a4bcd53", size = 5422810, upload-time = "2025-04-29T23:30:08.423Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/16/2ceb9fb7bc2b11b1e4a3ea27794256e93dee2309ebe297fd131a778cd150/orjson-3.10.18-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:a45e5d68066b408e4bc383b6e4ef05e717c65219a9e1390abc6155a520cac402", size = 248927, upload-time = "2025-04-29T23:28:08.643Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e1/d3c0a2bba5b9906badd121da449295062b289236c39c3a7801f92c4682b0/orjson-3.10.18-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:be3b9b143e8b9db05368b13b04c84d37544ec85bb97237b3a923f076265ec89c", size = 136995, upload-time = "2025-04-29T23:28:11.503Z" }, + { url = "https://files.pythonhosted.org/packages/d7/51/698dd65e94f153ee5ecb2586c89702c9e9d12f165a63e74eb9ea1299f4e1/orjson-3.10.18-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9b0aa09745e2c9b3bf779b096fa71d1cc2d801a604ef6dd79c8b1bfef52b2f92", size = 132893, upload-time = "2025-04-29T23:28:12.751Z" }, + { url = "https://files.pythonhosted.org/packages/b3/e5/155ce5a2c43a85e790fcf8b985400138ce5369f24ee6770378ee6b691036/orjson-3.10.18-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:53a245c104d2792e65c8d225158f2b8262749ffe64bc7755b00024757d957a13", size = 137017, upload-time = "2025-04-29T23:28:14.498Z" }, + { url = "https://files.pythonhosted.org/packages/46/bb/6141ec3beac3125c0b07375aee01b5124989907d61c72c7636136e4bd03e/orjson-3.10.18-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f9495ab2611b7f8a0a8a505bcb0f0cbdb5469caafe17b0e404c3c746f9900469", size = 138290, upload-time = "2025-04-29T23:28:16.211Z" }, + { url = "https://files.pythonhosted.org/packages/77/36/6961eca0b66b7809d33c4ca58c6bd4c23a1b914fb23aba2fa2883f791434/orjson-3.10.18-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:73be1cbcebadeabdbc468f82b087df435843c809cd079a565fb16f0f3b23238f", size = 142828, upload-time = "2025-04-29T23:28:18.065Z" }, + { url = "https://files.pythonhosted.org/packages/8b/2f/0c646d5fd689d3be94f4d83fa9435a6c4322c9b8533edbb3cd4bc8c5f69a/orjson-3.10.18-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fe8936ee2679e38903df158037a2f1c108129dee218975122e37847fb1d4ac68", size = 132806, upload-time = "2025-04-29T23:28:19.782Z" }, + { url = "https://files.pythonhosted.org/packages/ea/af/65907b40c74ef4c3674ef2bcfa311c695eb934710459841b3c2da212215c/orjson-3.10.18-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:7115fcbc8525c74e4c2b608129bef740198e9a120ae46184dac7683191042056", size = 135005, upload-time = "2025-04-29T23:28:21.367Z" }, + { url = "https://files.pythonhosted.org/packages/c7/d1/68bd20ac6a32cd1f1b10d23e7cc58ee1e730e80624e3031d77067d7150fc/orjson-3.10.18-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:771474ad34c66bc4d1c01f645f150048030694ea5b2709b87d3bda273ffe505d", size = 413418, upload-time = "2025-04-29T23:28:23.097Z" }, + { url = "https://files.pythonhosted.org/packages/31/31/c701ec0bcc3e80e5cb6e319c628ef7b768aaa24b0f3b4c599df2eaacfa24/orjson-3.10.18-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:7c14047dbbea52886dd87169f21939af5d55143dad22d10db6a7514f058156a8", size = 153288, upload-time = "2025-04-29T23:28:25.02Z" }, + { url = "https://files.pythonhosted.org/packages/d9/31/5e1aa99a10893a43cfc58009f9da840990cc8a9ebb75aa452210ba18587e/orjson-3.10.18-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:641481b73baec8db14fdf58f8967e52dc8bda1f2aba3aa5f5c1b07ed6df50b7f", size = 137181, upload-time = "2025-04-29T23:28:26.318Z" }, + { url = "https://files.pythonhosted.org/packages/bf/8c/daba0ac1b8690011d9242a0f37235f7d17df6d0ad941021048523b76674e/orjson-3.10.18-cp310-cp310-win32.whl", hash = "sha256:607eb3ae0909d47280c1fc657c4284c34b785bae371d007595633f4b1a2bbe06", size = 142694, upload-time = "2025-04-29T23:28:28.092Z" }, + { url = "https://files.pythonhosted.org/packages/16/62/8b687724143286b63e1d0fab3ad4214d54566d80b0ba9d67c26aaf28a2f8/orjson-3.10.18-cp310-cp310-win_amd64.whl", hash = "sha256:8770432524ce0eca50b7efc2a9a5f486ee0113a5fbb4231526d414e6254eba92", size = 134600, upload-time = "2025-04-29T23:28:29.422Z" }, + { url = "https://files.pythonhosted.org/packages/97/c7/c54a948ce9a4278794f669a353551ce7db4ffb656c69a6e1f2264d563e50/orjson-3.10.18-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:e0a183ac3b8e40471e8d843105da6fbe7c070faab023be3b08188ee3f85719b8", size = 248929, upload-time = "2025-04-29T23:28:30.716Z" }, + { url = "https://files.pythonhosted.org/packages/9e/60/a9c674ef1dd8ab22b5b10f9300e7e70444d4e3cda4b8258d6c2488c32143/orjson-3.10.18-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:5ef7c164d9174362f85238d0cd4afdeeb89d9e523e4651add6a5d458d6f7d42d", size = 133364, upload-time = "2025-04-29T23:28:32.392Z" }, + { url = "https://files.pythonhosted.org/packages/c1/4e/f7d1bdd983082216e414e6d7ef897b0c2957f99c545826c06f371d52337e/orjson-3.10.18-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:afd14c5d99cdc7bf93f22b12ec3b294931518aa019e2a147e8aa2f31fd3240f7", size = 136995, upload-time = "2025-04-29T23:28:34.024Z" }, + { url = "https://files.pythonhosted.org/packages/17/89/46b9181ba0ea251c9243b0c8ce29ff7c9796fa943806a9c8b02592fce8ea/orjson-3.10.18-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7b672502323b6cd133c4af6b79e3bea36bad2d16bca6c1f645903fce83909a7a", size = 132894, upload-time = "2025-04-29T23:28:35.318Z" }, + { url = "https://files.pythonhosted.org/packages/ca/dd/7bce6fcc5b8c21aef59ba3c67f2166f0a1a9b0317dcca4a9d5bd7934ecfd/orjson-3.10.18-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:51f8c63be6e070ec894c629186b1c0fe798662b8687f3d9fdfa5e401c6bd7679", size = 137016, upload-time = "2025-04-29T23:28:36.674Z" }, + { url = "https://files.pythonhosted.org/packages/1c/4a/b8aea1c83af805dcd31c1f03c95aabb3e19a016b2a4645dd822c5686e94d/orjson-3.10.18-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3f9478ade5313d724e0495d167083c6f3be0dd2f1c9c8a38db9a9e912cdaf947", size = 138290, upload-time = "2025-04-29T23:28:38.3Z" }, + { url = "https://files.pythonhosted.org/packages/36/d6/7eb05c85d987b688707f45dcf83c91abc2251e0dd9fb4f7be96514f838b1/orjson-3.10.18-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:187aefa562300a9d382b4b4eb9694806e5848b0cedf52037bb5c228c61bb66d4", size = 142829, upload-time = "2025-04-29T23:28:39.657Z" }, + { url = "https://files.pythonhosted.org/packages/d2/78/ddd3ee7873f2b5f90f016bc04062713d567435c53ecc8783aab3a4d34915/orjson-3.10.18-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9da552683bc9da222379c7a01779bddd0ad39dd699dd6300abaf43eadee38334", size = 132805, upload-time = "2025-04-29T23:28:40.969Z" }, + { url = "https://files.pythonhosted.org/packages/8c/09/c8e047f73d2c5d21ead9c180203e111cddeffc0848d5f0f974e346e21c8e/orjson-3.10.18-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e450885f7b47a0231979d9c49b567ed1c4e9f69240804621be87c40bc9d3cf17", size = 135008, upload-time = "2025-04-29T23:28:42.284Z" }, + { url = "https://files.pythonhosted.org/packages/0c/4b/dccbf5055ef8fb6eda542ab271955fc1f9bf0b941a058490293f8811122b/orjson-3.10.18-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:5e3c9cc2ba324187cd06287ca24f65528f16dfc80add48dc99fa6c836bb3137e", size = 413419, upload-time = "2025-04-29T23:28:43.673Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f3/1eac0c5e2d6d6790bd2025ebfbefcbd37f0d097103d76f9b3f9302af5a17/orjson-3.10.18-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:50ce016233ac4bfd843ac5471e232b865271d7d9d44cf9d33773bcd883ce442b", size = 153292, upload-time = "2025-04-29T23:28:45.573Z" }, + { url = "https://files.pythonhosted.org/packages/1f/b4/ef0abf64c8f1fabf98791819ab502c2c8c1dc48b786646533a93637d8999/orjson-3.10.18-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b3ceff74a8f7ffde0b2785ca749fc4e80e4315c0fd887561144059fb1c138aa7", size = 137182, upload-time = "2025-04-29T23:28:47.229Z" }, + { url = "https://files.pythonhosted.org/packages/a9/a3/6ea878e7b4a0dc5c888d0370d7752dcb23f402747d10e2257478d69b5e63/orjson-3.10.18-cp311-cp311-win32.whl", hash = "sha256:fdba703c722bd868c04702cac4cb8c6b8ff137af2623bc0ddb3b3e6a2c8996c1", size = 142695, upload-time = "2025-04-29T23:28:48.564Z" }, + { url = "https://files.pythonhosted.org/packages/79/2a/4048700a3233d562f0e90d5572a849baa18ae4e5ce4c3ba6247e4ece57b0/orjson-3.10.18-cp311-cp311-win_amd64.whl", hash = "sha256:c28082933c71ff4bc6ccc82a454a2bffcef6e1d7379756ca567c772e4fb3278a", size = 134603, upload-time = "2025-04-29T23:28:50.442Z" }, + { url = "https://files.pythonhosted.org/packages/03/45/10d934535a4993d27e1c84f1810e79ccf8b1b7418cef12151a22fe9bb1e1/orjson-3.10.18-cp311-cp311-win_arm64.whl", hash = "sha256:a6c7c391beaedd3fa63206e5c2b7b554196f14debf1ec9deb54b5d279b1b46f5", size = 131400, upload-time = "2025-04-29T23:28:51.838Z" }, + { url = "https://files.pythonhosted.org/packages/21/1a/67236da0916c1a192d5f4ccbe10ec495367a726996ceb7614eaa687112f2/orjson-3.10.18-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:50c15557afb7f6d63bc6d6348e0337a880a04eaa9cd7c9d569bcb4e760a24753", size = 249184, upload-time = "2025-04-29T23:28:53.612Z" }, + { url = "https://files.pythonhosted.org/packages/b3/bc/c7f1db3b1d094dc0c6c83ed16b161a16c214aaa77f311118a93f647b32dc/orjson-3.10.18-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:356b076f1662c9813d5fa56db7d63ccceef4c271b1fb3dd522aca291375fcf17", size = 133279, upload-time = "2025-04-29T23:28:55.055Z" }, + { url = "https://files.pythonhosted.org/packages/af/84/664657cd14cc11f0d81e80e64766c7ba5c9b7fc1ec304117878cc1b4659c/orjson-3.10.18-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:559eb40a70a7494cd5beab2d73657262a74a2c59aff2068fdba8f0424ec5b39d", size = 136799, upload-time = "2025-04-29T23:28:56.828Z" }, + { url = "https://files.pythonhosted.org/packages/9a/bb/f50039c5bb05a7ab024ed43ba25d0319e8722a0ac3babb0807e543349978/orjson-3.10.18-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f3c29eb9a81e2fbc6fd7ddcfba3e101ba92eaff455b8d602bf7511088bbc0eae", size = 132791, upload-time = "2025-04-29T23:28:58.751Z" }, + { url = "https://files.pythonhosted.org/packages/93/8c/ee74709fc072c3ee219784173ddfe46f699598a1723d9d49cbc78d66df65/orjson-3.10.18-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6612787e5b0756a171c7d81ba245ef63a3533a637c335aa7fcb8e665f4a0966f", size = 137059, upload-time = "2025-04-29T23:29:00.129Z" }, + { url = "https://files.pythonhosted.org/packages/6a/37/e6d3109ee004296c80426b5a62b47bcadd96a3deab7443e56507823588c5/orjson-3.10.18-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7ac6bd7be0dcab5b702c9d43d25e70eb456dfd2e119d512447468f6405b4a69c", size = 138359, upload-time = "2025-04-29T23:29:01.704Z" }, + { url = "https://files.pythonhosted.org/packages/4f/5d/387dafae0e4691857c62bd02839a3bf3fa648eebd26185adfac58d09f207/orjson-3.10.18-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9f72f100cee8dde70100406d5c1abba515a7df926d4ed81e20a9730c062fe9ad", size = 142853, upload-time = "2025-04-29T23:29:03.576Z" }, + { url = "https://files.pythonhosted.org/packages/27/6f/875e8e282105350b9a5341c0222a13419758545ae32ad6e0fcf5f64d76aa/orjson-3.10.18-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9dca85398d6d093dd41dc0983cbf54ab8e6afd1c547b6b8a311643917fbf4e0c", size = 133131, upload-time = "2025-04-29T23:29:05.753Z" }, + { url = "https://files.pythonhosted.org/packages/48/b2/73a1f0b4790dcb1e5a45f058f4f5dcadc8a85d90137b50d6bbc6afd0ae50/orjson-3.10.18-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:22748de2a07fcc8781a70edb887abf801bb6142e6236123ff93d12d92db3d406", size = 134834, upload-time = "2025-04-29T23:29:07.35Z" }, + { url = "https://files.pythonhosted.org/packages/56/f5/7ed133a5525add9c14dbdf17d011dd82206ca6840811d32ac52a35935d19/orjson-3.10.18-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:3a83c9954a4107b9acd10291b7f12a6b29e35e8d43a414799906ea10e75438e6", size = 413368, upload-time = "2025-04-29T23:29:09.301Z" }, + { url = "https://files.pythonhosted.org/packages/11/7c/439654221ed9c3324bbac7bdf94cf06a971206b7b62327f11a52544e4982/orjson-3.10.18-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:303565c67a6c7b1f194c94632a4a39918e067bd6176a48bec697393865ce4f06", size = 153359, upload-time = "2025-04-29T23:29:10.813Z" }, + { url = "https://files.pythonhosted.org/packages/48/e7/d58074fa0cc9dd29a8fa2a6c8d5deebdfd82c6cfef72b0e4277c4017563a/orjson-3.10.18-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:86314fdb5053a2f5a5d881f03fca0219bfdf832912aa88d18676a5175c6916b5", size = 137466, upload-time = "2025-04-29T23:29:12.26Z" }, + { url = "https://files.pythonhosted.org/packages/57/4d/fe17581cf81fb70dfcef44e966aa4003360e4194d15a3f38cbffe873333a/orjson-3.10.18-cp312-cp312-win32.whl", hash = "sha256:187ec33bbec58c76dbd4066340067d9ece6e10067bb0cc074a21ae3300caa84e", size = 142683, upload-time = "2025-04-29T23:29:13.865Z" }, + { url = "https://files.pythonhosted.org/packages/e6/22/469f62d25ab5f0f3aee256ea732e72dc3aab6d73bac777bd6277955bceef/orjson-3.10.18-cp312-cp312-win_amd64.whl", hash = "sha256:f9f94cf6d3f9cd720d641f8399e390e7411487e493962213390d1ae45c7814fc", size = 134754, upload-time = "2025-04-29T23:29:15.338Z" }, + { url = "https://files.pythonhosted.org/packages/10/b0/1040c447fac5b91bc1e9c004b69ee50abb0c1ffd0d24406e1350c58a7fcb/orjson-3.10.18-cp312-cp312-win_arm64.whl", hash = "sha256:3d600be83fe4514944500fa8c2a0a77099025ec6482e8087d7659e891f23058a", size = 131218, upload-time = "2025-04-29T23:29:17.324Z" }, + { url = "https://files.pythonhosted.org/packages/04/f0/8aedb6574b68096f3be8f74c0b56d36fd94bcf47e6c7ed47a7bd1474aaa8/orjson-3.10.18-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:69c34b9441b863175cc6a01f2935de994025e773f814412030f269da4f7be147", size = 249087, upload-time = "2025-04-29T23:29:19.083Z" }, + { url = "https://files.pythonhosted.org/packages/bc/f7/7118f965541aeac6844fcb18d6988e111ac0d349c9b80cda53583e758908/orjson-3.10.18-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:1ebeda919725f9dbdb269f59bc94f861afbe2a27dce5608cdba2d92772364d1c", size = 133273, upload-time = "2025-04-29T23:29:20.602Z" }, + { url = "https://files.pythonhosted.org/packages/fb/d9/839637cc06eaf528dd8127b36004247bf56e064501f68df9ee6fd56a88ee/orjson-3.10.18-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5adf5f4eed520a4959d29ea80192fa626ab9a20b2ea13f8f6dc58644f6927103", size = 136779, upload-time = "2025-04-29T23:29:22.062Z" }, + { url = "https://files.pythonhosted.org/packages/2b/6d/f226ecfef31a1f0e7d6bf9a31a0bbaf384c7cbe3fce49cc9c2acc51f902a/orjson-3.10.18-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7592bb48a214e18cd670974f289520f12b7aed1fa0b2e2616b8ed9e069e08595", size = 132811, upload-time = "2025-04-29T23:29:23.602Z" }, + { url = "https://files.pythonhosted.org/packages/73/2d/371513d04143c85b681cf8f3bce743656eb5b640cb1f461dad750ac4b4d4/orjson-3.10.18-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f872bef9f042734110642b7a11937440797ace8c87527de25e0c53558b579ccc", size = 137018, upload-time = "2025-04-29T23:29:25.094Z" }, + { url = "https://files.pythonhosted.org/packages/69/cb/a4d37a30507b7a59bdc484e4a3253c8141bf756d4e13fcc1da760a0b00cb/orjson-3.10.18-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0315317601149c244cb3ecef246ef5861a64824ccbcb8018d32c66a60a84ffbc", size = 138368, upload-time = "2025-04-29T23:29:26.609Z" }, + { url = "https://files.pythonhosted.org/packages/1e/ae/cd10883c48d912d216d541eb3db8b2433415fde67f620afe6f311f5cd2ca/orjson-3.10.18-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e0da26957e77e9e55a6c2ce2e7182a36a6f6b180ab7189315cb0995ec362e049", size = 142840, upload-time = "2025-04-29T23:29:28.153Z" }, + { url = "https://files.pythonhosted.org/packages/6d/4c/2bda09855c6b5f2c055034c9eda1529967b042ff8d81a05005115c4e6772/orjson-3.10.18-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb70d489bc79b7519e5803e2cc4c72343c9dc1154258adf2f8925d0b60da7c58", size = 133135, upload-time = "2025-04-29T23:29:29.726Z" }, + { url = "https://files.pythonhosted.org/packages/13/4a/35971fd809a8896731930a80dfff0b8ff48eeb5d8b57bb4d0d525160017f/orjson-3.10.18-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e9e86a6af31b92299b00736c89caf63816f70a4001e750bda179e15564d7a034", size = 134810, upload-time = "2025-04-29T23:29:31.269Z" }, + { url = "https://files.pythonhosted.org/packages/99/70/0fa9e6310cda98365629182486ff37a1c6578e34c33992df271a476ea1cd/orjson-3.10.18-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:c382a5c0b5931a5fc5405053d36c1ce3fd561694738626c77ae0b1dfc0242ca1", size = 413491, upload-time = "2025-04-29T23:29:33.315Z" }, + { url = "https://files.pythonhosted.org/packages/32/cb/990a0e88498babddb74fb97855ae4fbd22a82960e9b06eab5775cac435da/orjson-3.10.18-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8e4b2ae732431127171b875cb2668f883e1234711d3c147ffd69fe5be51a8012", size = 153277, upload-time = "2025-04-29T23:29:34.946Z" }, + { url = "https://files.pythonhosted.org/packages/92/44/473248c3305bf782a384ed50dd8bc2d3cde1543d107138fd99b707480ca1/orjson-3.10.18-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2d808e34ddb24fc29a4d4041dcfafbae13e129c93509b847b14432717d94b44f", size = 137367, upload-time = "2025-04-29T23:29:36.52Z" }, + { url = "https://files.pythonhosted.org/packages/ad/fd/7f1d3edd4ffcd944a6a40e9f88af2197b619c931ac4d3cfba4798d4d3815/orjson-3.10.18-cp313-cp313-win32.whl", hash = "sha256:ad8eacbb5d904d5591f27dee4031e2c1db43d559edb8f91778efd642d70e6bea", size = 142687, upload-time = "2025-04-29T23:29:38.292Z" }, + { url = "https://files.pythonhosted.org/packages/4b/03/c75c6ad46be41c16f4cfe0352a2d1450546f3c09ad2c9d341110cd87b025/orjson-3.10.18-cp313-cp313-win_amd64.whl", hash = "sha256:aed411bcb68bf62e85588f2a7e03a6082cc42e5a2796e06e72a962d7c6310b52", size = 134794, upload-time = "2025-04-29T23:29:40.349Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/f53038a5a72cc4fd0b56c1eafb4ef64aec9685460d5ac34de98ca78b6e29/orjson-3.10.18-cp313-cp313-win_arm64.whl", hash = "sha256:f54c1385a0e6aba2f15a40d703b858bedad36ded0491e55d35d905b2c34a4cc3", size = 131186, upload-time = "2025-04-29T23:29:41.922Z" }, +] + +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, +] + +[[package]] +name = "paginate" +version = "0.5.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/46/68dde5b6bc00c1296ec6466ab27dddede6aec9af1b99090e1107091b3b84/paginate-0.5.7.tar.gz", hash = "sha256:22bd083ab41e1a8b4f3690544afb2c60c25e5c9a63a30fa2f483f6c60c8e5945", size = 19252, upload-time = "2024-08-25T14:17:24.139Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/96/04b8e52da071d28f5e21a805b19cb9390aa17a47462ac87f5e2696b9566d/paginate-0.5.7-py2.py3-none-any.whl", hash = "sha256:b885e2af73abcf01d9559fd5216b57ef722f8c42affbb63942377668e35c7591", size = 13746, upload-time = "2024-08-25T14:17:22.55Z" }, +] + +[[package]] +name = "pandas" +version = "2.2.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "python-dateutil" }, + { name = "pytz" }, + { name = "tzdata" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9c/d6/9f8431bacc2e19dca897724cd097b1bb224a6ad5433784a44b587c7c13af/pandas-2.2.3.tar.gz", hash = "sha256:4f18ba62b61d7e192368b84517265a99b4d7ee8912f8708660fb4a366cc82667", size = 4399213, upload-time = "2024-09-20T13:10:04.827Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/aa/70/c853aec59839bceed032d52010ff5f1b8d87dc3114b762e4ba2727661a3b/pandas-2.2.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:1948ddde24197a0f7add2bdc4ca83bf2b1ef84a1bc8ccffd95eda17fd836ecb5", size = 12580827, upload-time = "2024-09-20T13:08:42.347Z" }, + { url = "https://files.pythonhosted.org/packages/99/f2/c4527768739ffa4469b2b4fff05aa3768a478aed89a2f271a79a40eee984/pandas-2.2.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:381175499d3802cde0eabbaf6324cce0c4f5d52ca6f8c377c29ad442f50f6348", size = 11303897, upload-time = "2024-09-20T13:08:45.807Z" }, + { url = "https://files.pythonhosted.org/packages/ed/12/86c1747ea27989d7a4064f806ce2bae2c6d575b950be087837bdfcabacc9/pandas-2.2.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d9c45366def9a3dd85a6454c0e7908f2b3b8e9c138f5dc38fed7ce720d8453ed", size = 66480908, upload-time = "2024-09-20T18:37:13.513Z" }, + { url = "https://files.pythonhosted.org/packages/44/50/7db2cd5e6373ae796f0ddad3675268c8d59fb6076e66f0c339d61cea886b/pandas-2.2.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:86976a1c5b25ae3f8ccae3a5306e443569ee3c3faf444dfd0f41cda24667ad57", size = 13064210, upload-time = "2024-09-20T13:08:48.325Z" }, + { url = "https://files.pythonhosted.org/packages/61/61/a89015a6d5536cb0d6c3ba02cebed51a95538cf83472975275e28ebf7d0c/pandas-2.2.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b8661b0238a69d7aafe156b7fa86c44b881387509653fdf857bebc5e4008ad42", size = 16754292, upload-time = "2024-09-20T19:01:54.443Z" }, + { url = "https://files.pythonhosted.org/packages/ce/0d/4cc7b69ce37fac07645a94e1d4b0880b15999494372c1523508511b09e40/pandas-2.2.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:37e0aced3e8f539eccf2e099f65cdb9c8aa85109b0be6e93e2baff94264bdc6f", size = 14416379, upload-time = "2024-09-20T13:08:50.882Z" }, + { url = "https://files.pythonhosted.org/packages/31/9e/6ebb433de864a6cd45716af52a4d7a8c3c9aaf3a98368e61db9e69e69a9c/pandas-2.2.3-cp310-cp310-win_amd64.whl", hash = "sha256:56534ce0746a58afaf7942ba4863e0ef81c9c50d3f0ae93e9497d6a41a057645", size = 11598471, upload-time = "2024-09-20T13:08:53.332Z" }, + { url = "https://files.pythonhosted.org/packages/a8/44/d9502bf0ed197ba9bf1103c9867d5904ddcaf869e52329787fc54ed70cc8/pandas-2.2.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:66108071e1b935240e74525006034333f98bcdb87ea116de573a6a0dccb6c039", size = 12602222, upload-time = "2024-09-20T13:08:56.254Z" }, + { url = "https://files.pythonhosted.org/packages/52/11/9eac327a38834f162b8250aab32a6781339c69afe7574368fffe46387edf/pandas-2.2.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7c2875855b0ff77b2a64a0365e24455d9990730d6431b9e0ee18ad8acee13dbd", size = 11321274, upload-time = "2024-09-20T13:08:58.645Z" }, + { url = "https://files.pythonhosted.org/packages/45/fb/c4beeb084718598ba19aa9f5abbc8aed8b42f90930da861fcb1acdb54c3a/pandas-2.2.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cd8d0c3be0515c12fed0bdbae072551c8b54b7192c7b1fda0ba56059a0179698", size = 15579836, upload-time = "2024-09-20T19:01:57.571Z" }, + { url = "https://files.pythonhosted.org/packages/cd/5f/4dba1d39bb9c38d574a9a22548c540177f78ea47b32f99c0ff2ec499fac5/pandas-2.2.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c124333816c3a9b03fbeef3a9f230ba9a737e9e5bb4060aa2107a86cc0a497fc", size = 13058505, upload-time = "2024-09-20T13:09:01.501Z" }, + { url = "https://files.pythonhosted.org/packages/b9/57/708135b90391995361636634df1f1130d03ba456e95bcf576fada459115a/pandas-2.2.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:63cc132e40a2e084cf01adf0775b15ac515ba905d7dcca47e9a251819c575ef3", size = 16744420, upload-time = "2024-09-20T19:02:00.678Z" }, + { url = "https://files.pythonhosted.org/packages/86/4a/03ed6b7ee323cf30404265c284cee9c65c56a212e0a08d9ee06984ba2240/pandas-2.2.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:29401dbfa9ad77319367d36940cd8a0b3a11aba16063e39632d98b0e931ddf32", size = 14440457, upload-time = "2024-09-20T13:09:04.105Z" }, + { url = "https://files.pythonhosted.org/packages/ed/8c/87ddf1fcb55d11f9f847e3c69bb1c6f8e46e2f40ab1a2d2abadb2401b007/pandas-2.2.3-cp311-cp311-win_amd64.whl", hash = "sha256:3fc6873a41186404dad67245896a6e440baacc92f5b716ccd1bc9ed2995ab2c5", size = 11617166, upload-time = "2024-09-20T13:09:06.917Z" }, + { url = "https://files.pythonhosted.org/packages/17/a3/fb2734118db0af37ea7433f57f722c0a56687e14b14690edff0cdb4b7e58/pandas-2.2.3-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:b1d432e8d08679a40e2a6d8b2f9770a5c21793a6f9f47fdd52c5ce1948a5a8a9", size = 12529893, upload-time = "2024-09-20T13:09:09.655Z" }, + { url = "https://files.pythonhosted.org/packages/e1/0c/ad295fd74bfac85358fd579e271cded3ac969de81f62dd0142c426b9da91/pandas-2.2.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a5a1595fe639f5988ba6a8e5bc9649af3baf26df3998a0abe56c02609392e0a4", size = 11363475, upload-time = "2024-09-20T13:09:14.718Z" }, + { url = "https://files.pythonhosted.org/packages/c6/2a/4bba3f03f7d07207481fed47f5b35f556c7441acddc368ec43d6643c5777/pandas-2.2.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5de54125a92bb4d1c051c0659e6fcb75256bf799a732a87184e5ea503965bce3", size = 15188645, upload-time = "2024-09-20T19:02:03.88Z" }, + { url = "https://files.pythonhosted.org/packages/38/f8/d8fddee9ed0d0c0f4a2132c1dfcf0e3e53265055da8df952a53e7eaf178c/pandas-2.2.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fffb8ae78d8af97f849404f21411c95062db1496aeb3e56f146f0355c9989319", size = 12739445, upload-time = "2024-09-20T13:09:17.621Z" }, + { url = "https://files.pythonhosted.org/packages/20/e8/45a05d9c39d2cea61ab175dbe6a2de1d05b679e8de2011da4ee190d7e748/pandas-2.2.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6dfcb5ee8d4d50c06a51c2fffa6cff6272098ad6540aed1a76d15fb9318194d8", size = 16359235, upload-time = "2024-09-20T19:02:07.094Z" }, + { url = "https://files.pythonhosted.org/packages/1d/99/617d07a6a5e429ff90c90da64d428516605a1ec7d7bea494235e1c3882de/pandas-2.2.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:062309c1b9ea12a50e8ce661145c6aab431b1e99530d3cd60640e255778bd43a", size = 14056756, upload-time = "2024-09-20T13:09:20.474Z" }, + { url = "https://files.pythonhosted.org/packages/29/d4/1244ab8edf173a10fd601f7e13b9566c1b525c4f365d6bee918e68381889/pandas-2.2.3-cp312-cp312-win_amd64.whl", hash = "sha256:59ef3764d0fe818125a5097d2ae867ca3fa64df032331b7e0917cf5d7bf66b13", size = 11504248, upload-time = "2024-09-20T13:09:23.137Z" }, + { url = "https://files.pythonhosted.org/packages/64/22/3b8f4e0ed70644e85cfdcd57454686b9057c6c38d2f74fe4b8bc2527214a/pandas-2.2.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f00d1345d84d8c86a63e476bb4955e46458b304b9575dcf71102b5c705320015", size = 12477643, upload-time = "2024-09-20T13:09:25.522Z" }, + { url = "https://files.pythonhosted.org/packages/e4/93/b3f5d1838500e22c8d793625da672f3eec046b1a99257666c94446969282/pandas-2.2.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3508d914817e153ad359d7e069d752cdd736a247c322d932eb89e6bc84217f28", size = 11281573, upload-time = "2024-09-20T13:09:28.012Z" }, + { url = "https://files.pythonhosted.org/packages/f5/94/6c79b07f0e5aab1dcfa35a75f4817f5c4f677931d4234afcd75f0e6a66ca/pandas-2.2.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22a9d949bfc9a502d320aa04e5d02feab689d61da4e7764b62c30b991c42c5f0", size = 15196085, upload-time = "2024-09-20T19:02:10.451Z" }, + { url = "https://files.pythonhosted.org/packages/e8/31/aa8da88ca0eadbabd0a639788a6da13bb2ff6edbbb9f29aa786450a30a91/pandas-2.2.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3a255b2c19987fbbe62a9dfd6cff7ff2aa9ccab3fc75218fd4b7530f01efa24", size = 12711809, upload-time = "2024-09-20T13:09:30.814Z" }, + { url = "https://files.pythonhosted.org/packages/ee/7c/c6dbdb0cb2a4344cacfb8de1c5808ca885b2e4dcfde8008266608f9372af/pandas-2.2.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:800250ecdadb6d9c78eae4990da62743b857b470883fa27f652db8bdde7f6659", size = 16356316, upload-time = "2024-09-20T19:02:13.825Z" }, + { url = "https://files.pythonhosted.org/packages/57/b7/8b757e7d92023b832869fa8881a992696a0bfe2e26f72c9ae9f255988d42/pandas-2.2.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6374c452ff3ec675a8f46fd9ab25c4ad0ba590b71cf0656f8b6daa5202bca3fb", size = 14022055, upload-time = "2024-09-20T13:09:33.462Z" }, + { url = "https://files.pythonhosted.org/packages/3b/bc/4b18e2b8c002572c5a441a64826252ce5da2aa738855747247a971988043/pandas-2.2.3-cp313-cp313-win_amd64.whl", hash = "sha256:61c5ad4043f791b61dd4752191d9f07f0ae412515d59ba8f005832a532f8736d", size = 11481175, upload-time = "2024-09-20T13:09:35.871Z" }, + { url = "https://files.pythonhosted.org/packages/76/a3/a5d88146815e972d40d19247b2c162e88213ef51c7c25993942c39dbf41d/pandas-2.2.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:3b71f27954685ee685317063bf13c7709a7ba74fc996b84fc6821c59b0f06468", size = 12615650, upload-time = "2024-09-20T13:09:38.685Z" }, + { url = "https://files.pythonhosted.org/packages/9c/8c/f0fd18f6140ddafc0c24122c8a964e48294acc579d47def376fef12bcb4a/pandas-2.2.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:38cf8125c40dae9d5acc10fa66af8ea6fdf760b2714ee482ca691fc66e6fcb18", size = 11290177, upload-time = "2024-09-20T13:09:41.141Z" }, + { url = "https://files.pythonhosted.org/packages/ed/f9/e995754eab9c0f14c6777401f7eece0943840b7a9fc932221c19d1abee9f/pandas-2.2.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ba96630bc17c875161df3818780af30e43be9b166ce51c9a18c1feae342906c2", size = 14651526, upload-time = "2024-09-20T19:02:16.905Z" }, + { url = "https://files.pythonhosted.org/packages/25/b0/98d6ae2e1abac4f35230aa756005e8654649d305df9a28b16b9ae4353bff/pandas-2.2.3-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1db71525a1538b30142094edb9adc10be3f3e176748cd7acc2240c2f2e5aa3a4", size = 11871013, upload-time = "2024-09-20T13:09:44.39Z" }, + { url = "https://files.pythonhosted.org/packages/cc/57/0f72a10f9db6a4628744c8e8f0df4e6e21de01212c7c981d31e50ffc8328/pandas-2.2.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:15c0e1e02e93116177d29ff83e8b1619c93ddc9c49083f237d4312337a61165d", size = 15711620, upload-time = "2024-09-20T19:02:20.639Z" }, + { url = "https://files.pythonhosted.org/packages/ab/5f/b38085618b950b79d2d9164a711c52b10aefc0ae6833b96f626b7021b2ed/pandas-2.2.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:ad5b65698ab28ed8d7f18790a0dc58005c7629f227be9ecc1072aa74c0c1d43a", size = 13098436, upload-time = "2024-09-20T13:09:48.112Z" }, +] + +[[package]] +name = "parso" +version = "0.8.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/66/94/68e2e17afaa9169cf6412ab0f28623903be73d1b32e208d9e8e541bb086d/parso-0.8.4.tar.gz", hash = "sha256:eb3a7b58240fb99099a345571deecc0f9540ea5f4dd2fe14c2a99d6b281ab92d", size = 400609, upload-time = "2024-04-05T09:43:55.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/ac/dac4a63f978e4dcb3c6d3a78c4d8e0192a113d288502a1216950c41b1027/parso-0.8.4-py2.py3-none-any.whl", hash = "sha256:a418670a20291dacd2dddc80c377c5c3791378ee1e8d12bffc35420643d43f18", size = 103650, upload-time = "2024-04-05T09:43:53.299Z" }, +] + +[[package]] +name = "pathspec" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043, upload-time = "2023-12-10T22:30:45Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191, upload-time = "2023-12-10T22:30:43.14Z" }, +] + +[[package]] +name = "pexpect" +version = "4.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ptyprocess" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523", size = 63772, upload-time = "2023-11-25T06:56:14.81Z" }, +] + +[[package]] +name = "pillow" +version = "10.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f8/3e/32cbd0129a28686621434cbf17bb64bf1458bfb838f1f668262fefce145c/pillow-10.2.0.tar.gz", hash = "sha256:e87f0b2c78157e12d7686b27d63c070fd65d994e8ddae6f328e0dcf4a0cd007e", size = 46212712, upload-time = "2024-01-02T09:16:59.702Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/92/a6eb4a8210d3597897ddf2d6af37898eb74e116bd2c6d2bcd9ac4080ebb5/pillow-10.2.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:7823bdd049099efa16e4246bdf15e5a13dbb18a51b68fa06d6c1d4d8b99a796e", size = 3518168, upload-time = "2024-01-02T09:15:01.151Z" }, + { url = "https://files.pythonhosted.org/packages/17/99/455970c10f53a3fe892a2b29ba2d094cd6820bdb739936a0336d8a09bd3d/pillow-10.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:83b2021f2ade7d1ed556bc50a399127d7fb245e725aa0113ebd05cfe88aaf588", size = 3318763, upload-time = "2024-01-02T09:15:05.098Z" }, + { url = "https://files.pythonhosted.org/packages/85/29/09797f258ecf1430a2066d942d0a6b5896d06c8fe44324c378ef9bd5cffe/pillow-10.2.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6fad5ff2f13d69b7e74ce5b4ecd12cc0ec530fcee76356cac6742785ff71c452", size = 4294639, upload-time = "2024-01-02T09:32:24.483Z" }, + { url = "https://files.pythonhosted.org/packages/73/0b/54df8b49ac8b85ed5aae68b2d8573ed1fb73d0a18a0830a988d0b3431080/pillow-10.2.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:da2b52b37dad6d9ec64e653637a096905b258d2fc2b984c41ae7d08b938a67e4", size = 4405870, upload-time = "2024-01-02T09:15:08.02Z" }, + { url = "https://files.pythonhosted.org/packages/85/ae/4a0c00b32ffe5d9bfb818bab140a0b260817ffa4d700ad0379901ba42999/pillow-10.2.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:47c0995fc4e7f79b5cfcab1fc437ff2890b770440f7696a3ba065ee0fd496563", size = 4319873, upload-time = "2024-01-02T09:32:38.285Z" }, + { url = "https://files.pythonhosted.org/packages/cb/c3/98faa3e92cf866b9446c4842f1fe847e672b2f54e000cb984157b8095797/pillow-10.2.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:322bdf3c9b556e9ffb18f93462e5f749d3444ce081290352c6070d014c93feb2", size = 4487681, upload-time = "2024-01-02T09:15:10.467Z" }, + { url = "https://files.pythonhosted.org/packages/c3/d7/0a90083a253b8382f6d56181b264daba3c95ddd425116edd7b90061b746a/pillow-10.2.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:51f1a1bffc50e2e9492e87d8e09a17c5eea8409cda8d3f277eb6edc82813c17c", size = 4514052, upload-time = "2024-01-02T09:32:46.376Z" }, + { url = "https://files.pythonhosted.org/packages/17/b8/1b8a7b1018b45a0d29a8f6b356c0b3d55c470da5e890433bd3bdba0d5713/pillow-10.2.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:69ffdd6120a4737710a9eee73e1d2e37db89b620f702754b8f6e62594471dee0", size = 4579647, upload-time = "2024-01-02T09:15:13.01Z" }, + { url = "https://files.pythonhosted.org/packages/45/44/cae1cb1abc50a97463094274f4c555f349340f7974ab13f929b4a633c4cd/pillow-10.2.0-cp310-cp310-win32.whl", hash = "sha256:c6dafac9e0f2b3c78df97e79af707cdc5ef8e88208d686a4847bab8266870023", size = 2289802, upload-time = "2024-01-02T09:15:15.314Z" }, + { url = "https://files.pythonhosted.org/packages/ef/d8/f97270d25a003435e408e6d1e38d8eddc9b3e2c7b646719f4b3a5293685d/pillow-10.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:aebb6044806f2e16ecc07b2a2637ee1ef67a11840a66752751714a0d924adf72", size = 2621373, upload-time = "2024-01-02T09:15:17.167Z" }, + { url = "https://files.pythonhosted.org/packages/cd/34/73761ac5cf8bd24c0e65d7ad828cbf59448ea5ae3508aed71f34ec80fb9f/pillow-10.2.0-cp310-cp310-win_arm64.whl", hash = "sha256:7049e301399273a0136ff39b84c3678e314f2158f50f517bc50285fb5ec847ad", size = 2228992, upload-time = "2024-01-02T09:15:19.33Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/23bafc80495b2a902b27d242e9226ea0b74624f108c60f0533329c051f78/pillow-10.2.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:35bb52c37f256f662abdfa49d2dfa6ce5d93281d323a9af377a120e89a9eafb5", size = 3518211, upload-time = "2024-01-02T09:15:21.874Z" }, + { url = "https://files.pythonhosted.org/packages/46/ce/a84284ab66a278825109b03765d7411be3ff18250da44faa9fb5ea9a16a0/pillow-10.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9c23f307202661071d94b5e384e1e1dc7dfb972a28a2310e4ee16103e66ddb67", size = 3318744, upload-time = "2024-01-02T09:15:24.732Z" }, + { url = "https://files.pythonhosted.org/packages/2c/36/57c68f5d03b471c4bd7302821b4fcb6f126ba91f78b590ffce00a8c2ac42/pillow-10.2.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:773efe0603db30c281521a7c0214cad7836c03b8ccff897beae9b47c0b657d61", size = 4304573, upload-time = "2024-01-02T09:32:51.962Z" }, + { url = "https://files.pythonhosted.org/packages/a5/23/3c59ba2bb48f2ab2f11c3597f50458f63ed46dcc4cedd3308f6e4ec7271f/pillow-10.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:11fa2e5984b949b0dd6d7a94d967743d87c577ff0b83392f17cb3990d0d2fd6e", size = 4414949, upload-time = "2024-01-02T09:15:27.503Z" }, + { url = "https://files.pythonhosted.org/packages/18/6c/04ef8c00c258df1f0f4ef940d76bc278d15693fbb3268da00b9f4b145ad6/pillow-10.2.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:716d30ed977be8b37d3ef185fecb9e5a1d62d110dfbdcd1e2a122ab46fddb03f", size = 4328040, upload-time = "2024-01-02T09:32:56.979Z" }, + { url = "https://files.pythonhosted.org/packages/66/9c/2e1877630eb298bbfd23f90deeec0a3f682a4163d5ca9f178937de57346c/pillow-10.2.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:a086c2af425c5f62a65e12fbf385f7c9fcb8f107d0849dba5839461a129cf311", size = 4494803, upload-time = "2024-01-02T09:15:30.346Z" }, + { url = "https://files.pythonhosted.org/packages/09/1f/b01ddb19acb325f1ee569cae9b914ce30f589f43d089e572ec6fd632f560/pillow-10.2.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:c8de2789052ed501dd829e9cae8d3dcce7acb4777ea4a479c14521c942d395b1", size = 4520153, upload-time = "2024-01-02T09:33:01.573Z" }, + { url = "https://files.pythonhosted.org/packages/ae/94/340ca3ee7b632c2019498e0f1d399530152f8c4e39f8374ace2fec147322/pillow-10.2.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:609448742444d9290fd687940ac0b57fb35e6fd92bdb65386e08e99af60bf757", size = 4585627, upload-time = "2024-01-02T09:15:33.069Z" }, + { url = "https://files.pythonhosted.org/packages/73/89/bef0d3a0e0c2cc054e055a38ca1ac210749b9537cb13b10f6fe0343eed79/pillow-10.2.0-cp311-cp311-win32.whl", hash = "sha256:823ef7a27cf86df6597fa0671066c1b596f69eba53efa3d1e1cb8b30f3533068", size = 2289835, upload-time = "2024-01-02T09:15:35.027Z" }, + { url = "https://files.pythonhosted.org/packages/43/56/f92715a873187b5eff72a4a0d2ac6258e18e9bfb0e136aafde65c49a841a/pillow-10.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:1da3b2703afd040cf65ec97efea81cfba59cdbed9c11d8efc5ab09df9509fc56", size = 2621395, upload-time = "2024-01-02T09:15:37.42Z" }, + { url = "https://files.pythonhosted.org/packages/b1/71/eea5f690e5f8d77cdde455d7e42bae0a2d918bec886f0e7fefb6836c51f4/pillow-10.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:edca80cbfb2b68d7b56930b84a0e45ae1694aeba0541f798e908a49d66b837f1", size = 2229075, upload-time = "2024-01-02T09:15:39.285Z" }, + { url = "https://files.pythonhosted.org/packages/37/d5/2c00228ace73a7855a52053a92fdd6cea9b22393fbf3961125c11829dcd2/pillow-10.2.0-cp312-cp312-macosx_10_10_x86_64.whl", hash = "sha256:1b5e1b74d1bd1b78bc3477528919414874748dd363e6272efd5abf7654e68bef", size = 3517780, upload-time = "2024-01-02T09:15:41.495Z" }, + { url = "https://files.pythonhosted.org/packages/9d/a0/28756da34d6b58c3c5f6c1d5589e4e8f4e73472b55875524ae9d6e7e98fe/pillow-10.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0eae2073305f451d8ecacb5474997c08569fb4eb4ac231ffa4ad7d342fdc25ac", size = 3317920, upload-time = "2024-01-02T09:15:44.116Z" }, + { url = "https://files.pythonhosted.org/packages/ab/72/e6a8887c0ce6c94cd0b74fef495a81f4ea4c742242de4bc1943abbd21f92/pillow-10.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b7c2286c23cd350b80d2fc9d424fc797575fb16f854b831d16fd47ceec078f2c", size = 4308358, upload-time = "2024-01-02T09:33:09.603Z" }, + { url = "https://files.pythonhosted.org/packages/a8/2f/86cf1dc4b0530e4c3e96edd0338dcc4809c2622d9d45460029a71a831473/pillow-10.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1e23412b5c41e58cec602f1135c57dfcf15482013ce6e5f093a86db69646a5aa", size = 4422007, upload-time = "2024-01-02T09:15:46.355Z" }, + { url = "https://files.pythonhosted.org/packages/00/43/1ca3313b56ef623de0afebfe3d7a6e9c07e1a76c50ce191302018907b2b5/pillow-10.2.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:52a50aa3fb3acb9cf7213573ef55d31d6eca37f5709c69e6858fe3bc04a5c2a2", size = 4333841, upload-time = "2024-01-02T09:33:14.842Z" }, + { url = "https://files.pythonhosted.org/packages/5c/c6/5b6b1f7362267494a423b45af684d604491565e81436e3ebeefee68f78fd/pillow-10.2.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:127cee571038f252a552760076407f9cff79761c3d436a12af6000cd182a9d04", size = 4502101, upload-time = "2024-01-02T09:15:48.416Z" }, + { url = "https://files.pythonhosted.org/packages/e6/c5/37e72d74c248adf133a2dd56890cf8632e2e46562e5fa70414445bbd3ae6/pillow-10.2.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:8d12251f02d69d8310b046e82572ed486685c38f02176bd08baf216746eb947f", size = 4542122, upload-time = "2024-01-02T09:33:19.012Z" }, + { url = "https://files.pythonhosted.org/packages/fa/93/79979b8ab99da2958bf6fef1be745c344c4e727f07d1429c49c015e21db2/pillow-10.2.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:54f1852cd531aa981bc0965b7d609f5f6cc8ce8c41b1139f6ed6b3c54ab82bfb", size = 4611042, upload-time = "2024-01-02T09:15:50.616Z" }, + { url = "https://files.pythonhosted.org/packages/ce/a7/11a539c1e12dfb9d67c35e5d3d99c7a6853face9083e6483360f4d9cd1d8/pillow-10.2.0-cp312-cp312-win32.whl", hash = "sha256:257d8788df5ca62c980314053197f4d46eefedf4e6175bc9412f14412ec4ea2f", size = 2290438, upload-time = "2024-01-02T09:15:53.219Z" }, + { url = "https://files.pythonhosted.org/packages/51/07/7e9266a59bb267b56c1f432f6416653b9a78dda771c57740d064a8aa2a44/pillow-10.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:154e939c5f0053a383de4fd3d3da48d9427a7e985f58af8e94d0b3c9fcfcf4f9", size = 2621845, upload-time = "2024-01-02T09:15:55.293Z" }, + { url = "https://files.pythonhosted.org/packages/a0/61/6cff8a8dbbac3d7fb7adb435b60737a7d0b0849f53e3af38f2c94d988da6/pillow-10.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:f379abd2f1e3dddb2b61bc67977a6b5a0a3f7485538bcc6f39ec76163891ee48", size = 2229322, upload-time = "2024-01-02T09:15:57.475Z" }, + { url = "https://files.pythonhosted.org/packages/4f/60/978be50cd6a915c719f5c2b9bdcc50d7a077325bbf1b42ac2cda3699bbd8/pillow-10.2.0-pp310-pypy310_pp73-macosx_10_10_x86_64.whl", hash = "sha256:322209c642aabdd6207517e9739c704dc9f9db943015535783239022002f054a", size = 3471903, upload-time = "2024-01-02T09:16:37.837Z" }, + { url = "https://files.pythonhosted.org/packages/c5/01/f7711289cbd0e9503195f0579242d46fc7b64dc2ed1ce6a31b2972a6e074/pillow-10.2.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3eedd52442c0a5ff4f887fab0c1c0bb164d8635b32c894bc1faf4c618dd89df2", size = 3404156, upload-time = "2024-01-02T09:34:02.094Z" }, + { url = "https://files.pythonhosted.org/packages/8e/70/8520fb8c5f15a17ffb285be01b79186e89fe5563a05470677ca3f5668beb/pillow-10.2.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cb28c753fd5eb3dd859b4ee95de66cc62af91bcff5db5f2571d32a520baf1f04", size = 3458621, upload-time = "2024-01-02T09:16:39.987Z" }, + { url = "https://files.pythonhosted.org/packages/a6/0b/18363dec5f6b3882f7c4dc9cee23dfc3fefa4a7350ff5a98290365734350/pillow-10.2.0-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:33870dc4653c5017bf4c8873e5488d8f8d5f8935e2f1fb9a2208c47cdd66efd2", size = 3445891, upload-time = "2024-01-02T09:34:09.389Z" }, + { url = "https://files.pythonhosted.org/packages/d7/70/0e076ee40ffbf2130408dc64195d6505770aba2eb30d07af5bc6f2f45ffb/pillow-10.2.0-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:3c31822339516fb3c82d03f30e22b1d038da87ef27b6a78c9549888f8ceda39a", size = 3547896, upload-time = "2024-01-02T09:16:42.204Z" }, + { url = "https://files.pythonhosted.org/packages/08/c1/b5218b5e4966c872bdae69c679b7d8f6e1ebd3338df47659d6c314b99c54/pillow-10.2.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:a2b56ba36e05f973d450582fb015594aaa78834fefe8dfb8fcd79b93e64ba4c6", size = 2621764, upload-time = "2024-01-02T09:16:44.36Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.3.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/8b/3c73abc9c759ecd3f1f7ceff6685840859e8070c4d947c93fae71f6a0bf2/platformdirs-4.3.8.tar.gz", hash = "sha256:3d512d96e16bcb959a814c9f348431070822a6496326a4be0911c40b5a74c2bc", size = 21362, upload-time = "2025-05-07T22:47:42.121Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/39/979e8e21520d4e47a0bbe349e2713c0aac6f3d853d0e5b34d76206c439aa/platformdirs-4.3.8-py3-none-any.whl", hash = "sha256:ff7059bb7eb1179e2685604f4aaf157cfd9535242bd23742eadc3c13542139b4", size = 18567, upload-time = "2025-05-07T22:47:40.376Z" }, +] + +[[package]] +name = "plotly" +version = "6.1.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "narwhals" }, + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/77/431447616eda6a432dc3ce541b3f808ecb8803ea3d4ab2573b67f8eb4208/plotly-6.1.2.tar.gz", hash = "sha256:4fdaa228926ba3e3a213f4d1713287e69dcad1a7e66cf2025bd7d7026d5014b4", size = 7662971, upload-time = "2025-05-27T20:21:52.56Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/6f/759d5da0517547a5d38aabf05d04d9f8adf83391d2c7fc33f904417d3ba2/plotly-6.1.2-py3-none-any.whl", hash = "sha256:f1548a8ed9158d59e03d7fed548c7db5549f3130d9ae19293c8638c202648f6d", size = 16265530, upload-time = "2025-05-27T20:21:46.6Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "portalocker" +version = "3.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pywin32", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/91/8bfe23e1f7f630f2061ef38b5225d9fda9068d6a30fcbc187951e678e630/portalocker-3.1.1.tar.gz", hash = "sha256:ec20f6dda2ad9ce89fa399a5f31f4f1495f515958f0cb7ca6543cef7bb5a749e", size = 43708, upload-time = "2024-12-31T14:22:48.535Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/60/1974cfdd5bb770568ddc6f89f3e0df4cfdd1acffd5a609dff5e95f48c6e2/portalocker-3.1.1-py3-none-any.whl", hash = "sha256:80e984e24de292ff258a5bea0e4f3f778fff84c0ae1275dbaebc4658de4aacb3", size = 19661, upload-time = "2024-12-31T14:22:47.019Z" }, +] + +[[package]] +name = "pre-commit" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/08/39/679ca9b26c7bb2999ff122d50faa301e49af82ca9c066ec061cfbc0c6784/pre_commit-4.2.0.tar.gz", hash = "sha256:601283b9757afd87d40c4c4a9b2b5de9637a8ea02eaff7adc2d0fb4e04841146", size = 193424, upload-time = "2025-03-18T21:35:20.987Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/74/a88bf1b1efeae488a0c0b7bdf71429c313722d1fc0f377537fbe554e6180/pre_commit-4.2.0-py2.py3-none-any.whl", hash = "sha256:a009ca7205f1eb497d10b845e52c838a98b6cdd2102a6c8e4540e94ee75c58bd", size = 220707, upload-time = "2025-03-18T21:35:19.343Z" }, +] + +[[package]] +name = "prompt-toolkit" +version = "3.0.51" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/6e/9d084c929dfe9e3bfe0c6a47e31f78a25c54627d64a66e884a8bf5474f1c/prompt_toolkit-3.0.51.tar.gz", hash = "sha256:931a162e3b27fc90c86f1b48bb1fb2c528c2761475e57c9c06de13311c7b54ed", size = 428940, upload-time = "2025-04-15T09:18:47.731Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/4f/5249960887b1fbe561d9ff265496d170b55a735b76724f10ef19f9e40716/prompt_toolkit-3.0.51-py3-none-any.whl", hash = "sha256:52742911fde84e2d423e2f9a4cf1de7d7ac4e51958f648d9540e0fb8db077b07", size = 387810, upload-time = "2025-04-15T09:18:44.753Z" }, +] + +[[package]] +name = "protobuf" +version = "6.31.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/48/718c1e104a2e89970a8ff3b06d87e152834b576c570a6908f8c17ba88d65/protobuf-6.31.0.tar.gz", hash = "sha256:314fab1a6a316469dc2dd46f993cbbe95c861ea6807da910becfe7475bc26ffe", size = 441644, upload-time = "2025-05-14T17:58:27.862Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/77/8671682038b08237c927215fa3296bc1c54e4086fe542c87017c1b626663/protobuf-6.31.0-cp310-abi3-win32.whl", hash = "sha256:10bd62802dfa0588649740a59354090eaf54b8322f772fbdcca19bc78d27f0d6", size = 423437, upload-time = "2025-05-14T17:58:16.116Z" }, + { url = "https://files.pythonhosted.org/packages/e4/07/cc9b0cbf7593f6ef8cf87fa9b0e55cd74c5cb526dd89ad84aa7d6547ef8d/protobuf-6.31.0-cp310-abi3-win_amd64.whl", hash = "sha256:3e987c99fd634be8347246a02123250f394ba20573c953de133dc8b2c107dd71", size = 435118, upload-time = "2025-05-14T17:58:18.591Z" }, + { url = "https://files.pythonhosted.org/packages/21/46/33f884aa8bc59114dc97e0d954ca4618c556483670236008c88fbb7e834f/protobuf-6.31.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:2c812f0f96ceb6b514448cefeb1df54ec06dde456783f5099c0e2f8a0f2caa89", size = 425439, upload-time = "2025-05-14T17:58:19.709Z" }, + { url = "https://files.pythonhosted.org/packages/9b/f2/9a676b50229ce37b12777d7b21de90ae7bc0f9505d07e72e2e8d47b8d165/protobuf-6.31.0-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:67ce50195e4e584275623b8e6bc6d3d3dfd93924bf6116b86b3b8975ab9e4571", size = 321950, upload-time = "2025-05-14T17:58:22.04Z" }, + { url = "https://files.pythonhosted.org/packages/a1/a7/243fa2d3c1b7675d54744b32dacf30356f4c27c0d3ad940ca8745a1c6b2c/protobuf-6.31.0-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:5353e38844168a327acd2b2aa440044411cd8d1b6774d5701008bd1dba067c79", size = 320904, upload-time = "2025-05-14T17:58:23.438Z" }, + { url = "https://files.pythonhosted.org/packages/ee/01/1ed1d482960a5718fd99c82f6d79120181947cfd4667ec3944d448ed44a3/protobuf-6.31.0-py3-none-any.whl", hash = "sha256:6ac2e82556e822c17a8d23aa1190bbc1d06efb9c261981da95c71c9da09e9e23", size = 168558, upload-time = "2025-05-14T17:58:26.923Z" }, +] + +[[package]] +name = "psutil" +version = "7.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2a/80/336820c1ad9286a4ded7e845b2eccfcb27851ab8ac6abece774a6ff4d3de/psutil-7.0.0.tar.gz", hash = "sha256:7be9c3eba38beccb6495ea33afd982a44074b78f28c434a1f51cc07fd315c456", size = 497003, upload-time = "2025-02-13T21:54:07.946Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ed/e6/2d26234410f8b8abdbf891c9da62bee396583f713fb9f3325a4760875d22/psutil-7.0.0-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:101d71dc322e3cffd7cea0650b09b3d08b8e7c4109dd6809fe452dfd00e58b25", size = 238051, upload-time = "2025-02-13T21:54:12.36Z" }, + { url = "https://files.pythonhosted.org/packages/04/8b/30f930733afe425e3cbfc0e1468a30a18942350c1a8816acfade80c005c4/psutil-7.0.0-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:39db632f6bb862eeccf56660871433e111b6ea58f2caea825571951d4b6aa3da", size = 239535, upload-time = "2025-02-13T21:54:16.07Z" }, + { url = "https://files.pythonhosted.org/packages/2a/ed/d362e84620dd22876b55389248e522338ed1bf134a5edd3b8231d7207f6d/psutil-7.0.0-cp36-abi3-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1fcee592b4c6f146991ca55919ea3d1f8926497a713ed7faaf8225e174581e91", size = 275004, upload-time = "2025-02-13T21:54:18.662Z" }, + { url = "https://files.pythonhosted.org/packages/bf/b9/b0eb3f3cbcb734d930fdf839431606844a825b23eaf9a6ab371edac8162c/psutil-7.0.0-cp36-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b1388a4f6875d7e2aff5c4ca1cc16c545ed41dd8bb596cefea80111db353a34", size = 277986, upload-time = "2025-02-13T21:54:21.811Z" }, + { url = "https://files.pythonhosted.org/packages/eb/a2/709e0fe2f093556c17fbafda93ac032257242cabcc7ff3369e2cb76a97aa/psutil-7.0.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a5f098451abc2828f7dc6b58d44b532b22f2088f4999a937557b603ce72b1993", size = 279544, upload-time = "2025-02-13T21:54:24.68Z" }, + { url = "https://files.pythonhosted.org/packages/50/e6/eecf58810b9d12e6427369784efe814a1eec0f492084ce8eb8f4d89d6d61/psutil-7.0.0-cp37-abi3-win32.whl", hash = "sha256:ba3fcef7523064a6c9da440fc4d6bd07da93ac726b5733c29027d7dc95b39d99", size = 241053, upload-time = "2025-02-13T21:54:34.31Z" }, + { url = "https://files.pythonhosted.org/packages/50/1b/6921afe68c74868b4c9fa424dad3be35b095e16687989ebbb50ce4fceb7c/psutil-7.0.0-cp37-abi3-win_amd64.whl", hash = "sha256:4cf3d4eb1aa9b348dec30105c55cd9b7d4629285735a102beb4441e38db90553", size = 244885, upload-time = "2025-02-13T21:54:37.486Z" }, +] + +[[package]] +name = "ptyprocess" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/e5/16ff212c1e452235a90aeb09066144d0c5a6a8c0834397e03f5224495c4e/ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220", size = 70762, upload-time = "2020-12-28T15:15:30.155Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35", size = 13993, upload-time = "2020-12-28T15:15:28.35Z" }, +] + +[[package]] +name = "pure-eval" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/05/0a34433a064256a578f1783a10da6df098ceaa4a57bbeaa96a6c0352786b/pure_eval-0.2.3.tar.gz", hash = "sha256:5f4e983f40564c576c7c8635ae88db5956bb2229d7e9237d03b3c0b0190eaf42", size = 19752, upload-time = "2024-07-21T12:58:21.801Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/37/efad0257dc6e593a18957422533ff0f87ede7c9c6ea010a2177d738fb82f/pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0", size = 11842, upload-time = "2024-07-21T12:58:20.04Z" }, +] + +[[package]] +name = "pycocotools" +version = "2.0.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "matplotlib" }, + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6b/1a/cdfce175d663568215b3a6b6170ad2a526932cc1021dffabda56a5c3f189/pycocotools-2.0.8.tar.gz", hash = "sha256:8f2bcedb786ba26c367a3680f9c4eb5b2ad9dccb2b34eaeb205e0a021e1dfb8d", size = 24993, upload-time = "2024-06-17T04:26:51.711Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/03/8738c457ca04aed97f79781827b20862e78262da7ccc8062bcc6d6e857e2/pycocotools-2.0.8-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:9a66886f45b04cee1ff0492e9f5e25d430d8aa3eb63e63c4ebc620945caa11b9", size = 162301, upload-time = "2024-06-17T04:26:15.323Z" }, + { url = "https://files.pythonhosted.org/packages/ad/0a/bcd4592a85896a4281bb8ec5dd034ce12d82bb26b6e73e73b3c435377db1/pycocotools-2.0.8-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:257130b65b7b0f122ce1ed62942867ca9789e56a68109682796cc85c9770c74a", size = 410644, upload-time = "2024-06-17T04:26:17.049Z" }, + { url = "https://files.pythonhosted.org/packages/6a/03/6c0bf810a5df7876caaf11f5b113e7ffd4b2fa9767d360489c6fdcefe8e5/pycocotools-2.0.8-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:663c14cd471913aabecb17ddb52b3b254a65dcaba26ccfea408c52c75cc3862c", size = 427769, upload-time = "2024-06-17T04:26:18.329Z" }, + { url = "https://files.pythonhosted.org/packages/03/76/587579abcf3bab2b5a9b89ee28e78bef3df3198d724a4980b0875f69586b/pycocotools-2.0.8-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:35a6ef931448632efe1c83eb2ac3c37c53b3c080a5432bc6ff1858944a603a2d", size = 408920, upload-time = "2024-06-17T04:26:19.845Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d2/57421216b31920eb942bd8a81cead5e9b42dfd433e15d682cd7e156b6f84/pycocotools-2.0.8-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:e7b4ee8b15539d6f789857faefe7d3eef81755f7b17f60903798524e4f321a5c", size = 426178, upload-time = "2024-06-17T04:26:21.537Z" }, + { url = "https://files.pythonhosted.org/packages/8d/06/b9bdedfdcbf2fb5ba55252f1a5ff5e8e02ae204fe392f7b4f5babbc14a2a/pycocotools-2.0.8-cp310-cp310-win_amd64.whl", hash = "sha256:889edd2dbf61f4d2fe77c2e8e5608476903d1911d2ed00f9911354eff23f2423", size = 84484, upload-time = "2024-06-17T04:26:23.078Z" }, + { url = "https://files.pythonhosted.org/packages/05/90/52de34f2f032e3de957c953fd1d4a9025175622714e5023ba4d6a9a96ece/pycocotools-2.0.8-cp310-cp310-win_arm64.whl", hash = "sha256:52e06a833fad735485cad5c1f8fe40e2b586261b2856806b5d6923b0b5a3c971", size = 70968, upload-time = "2024-06-17T04:26:24.494Z" }, + { url = "https://files.pythonhosted.org/packages/6b/56/9eedccfd1cfdaf6553d527bed0b2b5572550567a5786a8beb098027a3e5e/pycocotools-2.0.8-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:92bf788e6936fc52b57ccaaa78ecdaeac81872eebbfc45b6fe16ae18b85709bd", size = 162868, upload-time = "2024-06-17T04:26:25.412Z" }, + { url = "https://files.pythonhosted.org/packages/d5/9c/09cd808743338db170915deb35fa020b792d583238afe55f27c011f91c3c/pycocotools-2.0.8-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6a07f57f991e379959c0f4a1b9ea35d875876433b7f45c6d8fe6b718e58834bc", size = 443318, upload-time = "2024-06-17T04:26:26.452Z" }, + { url = "https://files.pythonhosted.org/packages/8b/d4/7279d072c0255d07c541326f6058effb1b08190f49695bf2c22aae666878/pycocotools-2.0.8-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5968a1e5421719af9eb7ccee4c540bfb18b1fc95d30d9a48571d0aaeb159a1ae", size = 458661, upload-time = "2024-06-17T04:26:27.917Z" }, + { url = "https://files.pythonhosted.org/packages/33/b7/886f5ceb83cfefe52d14b4df7da034deecddf714b4ff2c75d98ee35469cd/pycocotools-2.0.8-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:59eb7b1839f269262456347b6fe2bb88a8be56b32d87fab946483746e1f18a07", size = 438662, upload-time = "2024-06-17T04:26:29.712Z" }, + { url = "https://files.pythonhosted.org/packages/cf/0f/890e1e5d6c9f773fb5f5903ca8f75425b1c0cec8f71c1322f481f26a0138/pycocotools-2.0.8-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:05480f731fcd7c5d05389081f84198f3b8117f4560227185bc462cccb5c79181", size = 456444, upload-time = "2024-06-17T04:26:31.007Z" }, + { url = "https://files.pythonhosted.org/packages/2e/f5/dfa78dc72e47dfe1ada7b37fedcb338454750470358a6dfcfdfda35fa337/pycocotools-2.0.8-cp311-cp311-win_amd64.whl", hash = "sha256:e680e27e58b840c105fa09a3bb1d91706038c5c8d7b7bf09c2e5ecbd1b05ad7f", size = 85304, upload-time = "2024-06-17T04:26:32.365Z" }, + { url = "https://files.pythonhosted.org/packages/43/2a/7a461713fd3ff474bd12420b8e402c248b7821f295031f2ac632c0949740/pycocotools-2.0.8-cp311-cp311-win_arm64.whl", hash = "sha256:16c5a1d2c8726149b5a0e6fe95095ffc172d4012ece5dee9b5beeef708fc0284", size = 71417, upload-time = "2024-06-17T04:26:33.271Z" }, + { url = "https://files.pythonhosted.org/packages/20/b6/d3287bdb2f1954d5739337035a424b6ec012bc6fed0af476c92309cec001/pycocotools-2.0.8-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:dd4616621d062882db677de5c64b1b0f6efbcaed9fd284b61e7ba4b16ab24d7a", size = 162686, upload-time = "2024-06-17T04:26:34.317Z" }, + { url = "https://files.pythonhosted.org/packages/ce/1d/3f32a8fd8b0d0c6f952f030ac90fceb318204c19de33b1cbc4cccee51a03/pycocotools-2.0.8-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5683ba2612c39094a2e8453d40349768a3da6673376786651481d6f553ff7b50", size = 429594, upload-time = "2024-06-17T04:26:35.411Z" }, + { url = "https://files.pythonhosted.org/packages/3c/ce/e51566bce4067327c299fe8b6de18f9275e0c0ceaf8e4820ea9af689101c/pycocotools-2.0.8-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b89f399eb851d18f68dfa7f126380394ec0820915c7b3831dd37563bc58daa95", size = 443497, upload-time = "2024-06-17T04:26:37.05Z" }, + { url = "https://files.pythonhosted.org/packages/87/f2/038244a12c3297a2a7821bd6e72deaa350831c142b0380a14c9749009d83/pycocotools-2.0.8-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:e6d528c4f35580347ee3cd57f92cf0926e9b6a688d0904b2ea8a814ae2e57a47", size = 428855, upload-time = "2024-06-17T04:26:38.191Z" }, + { url = "https://files.pythonhosted.org/packages/74/fd/88025b72eaff58fe4066823ebecb3232c3b59f2a080cb3d4c974e1082732/pycocotools-2.0.8-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:56bbe8be608def61da0b4430562b8d5ff14525f509631a667cfd8405325193da", size = 444322, upload-time = "2024-06-17T04:26:39.882Z" }, + { url = "https://files.pythonhosted.org/packages/4a/9b/8f89d36e4a23166ccabe5c9fed00baffaa6a67609add316fc1334bbf4016/pycocotools-2.0.8-cp312-cp312-win_amd64.whl", hash = "sha256:d004033e760a172b2ccbdf4a62d20d2bcf0c9b40dc3c0d1d724045b0a6944862", size = 83255, upload-time = "2024-06-17T04:26:41.178Z" }, + { url = "https://files.pythonhosted.org/packages/4d/82/73ba66a13b2288ecc60ed910dd8c16e6c584f3ca5407e706e5903d256712/pycocotools-2.0.8-cp312-cp312-win_arm64.whl", hash = "sha256:87853ca11e9b130e461d6b5284ea475efe35429060a915844e1998d206ba028e", size = 68922, upload-time = "2024-06-17T04:26:42.086Z" }, +] + +[[package]] +name = "pycparser" +version = "2.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/b2/31537cf4b1ca988837256c910a668b553fceb8f069bedc4b1c826024b52c/pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6", size = 172736, upload-time = "2024-03-30T13:22:22.564Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", size = 117552, upload-time = "2024-03-30T13:22:20.476Z" }, +] + +[[package]] +name = "pydantic" +version = "2.11.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f0/86/8ce9040065e8f924d642c58e4a344e33163a07f6b57f836d0d734e0ad3fb/pydantic-2.11.5.tar.gz", hash = "sha256:7f853db3d0ce78ce8bbb148c401c2cdd6431b3473c0cdff2755c7690952a7b7a", size = 787102, upload-time = "2025-05-22T21:18:08.761Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/69/831ed22b38ff9b4b64b66569f0e5b7b97cf3638346eb95a2147fdb49ad5f/pydantic-2.11.5-py3-none-any.whl", hash = "sha256:f9c26ba06f9747749ca1e5c94d6a85cb84254577553c8785576fd38fa64dc0f7", size = 444229, upload-time = "2025-05-22T21:18:06.329Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.33.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ad/88/5f2260bdfae97aabf98f1778d43f69574390ad787afb646292a638c923d4/pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc", size = 435195, upload-time = "2025-04-23T18:33:52.104Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/92/b31726561b5dae176c2d2c2dc43a9c5bfba5d32f96f8b4c0a600dd492447/pydantic_core-2.33.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2b3d326aaef0c0399d9afffeb6367d5e26ddc24d351dbc9c636840ac355dc5d8", size = 2028817, upload-time = "2025-04-23T18:30:43.919Z" }, + { url = "https://files.pythonhosted.org/packages/a3/44/3f0b95fafdaca04a483c4e685fe437c6891001bf3ce8b2fded82b9ea3aa1/pydantic_core-2.33.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e5b2671f05ba48b94cb90ce55d8bdcaaedb8ba00cc5359f6810fc918713983d", size = 1861357, upload-time = "2025-04-23T18:30:46.372Z" }, + { url = "https://files.pythonhosted.org/packages/30/97/e8f13b55766234caae05372826e8e4b3b96e7b248be3157f53237682e43c/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0069c9acc3f3981b9ff4cdfaf088e98d83440a4c7ea1bc07460af3d4dc22e72d", size = 1898011, upload-time = "2025-04-23T18:30:47.591Z" }, + { url = "https://files.pythonhosted.org/packages/9b/a3/99c48cf7bafc991cc3ee66fd544c0aae8dc907b752f1dad2d79b1b5a471f/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d53b22f2032c42eaaf025f7c40c2e3b94568ae077a606f006d206a463bc69572", size = 1982730, upload-time = "2025-04-23T18:30:49.328Z" }, + { url = "https://files.pythonhosted.org/packages/de/8e/a5b882ec4307010a840fb8b58bd9bf65d1840c92eae7534c7441709bf54b/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0405262705a123b7ce9f0b92f123334d67b70fd1f20a9372b907ce1080c7ba02", size = 2136178, upload-time = "2025-04-23T18:30:50.907Z" }, + { url = "https://files.pythonhosted.org/packages/e4/bb/71e35fc3ed05af6834e890edb75968e2802fe98778971ab5cba20a162315/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4b25d91e288e2c4e0662b8038a28c6a07eaac3e196cfc4ff69de4ea3db992a1b", size = 2736462, upload-time = "2025-04-23T18:30:52.083Z" }, + { url = "https://files.pythonhosted.org/packages/31/0d/c8f7593e6bc7066289bbc366f2235701dcbebcd1ff0ef8e64f6f239fb47d/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6bdfe4b3789761f3bcb4b1ddf33355a71079858958e3a552f16d5af19768fef2", size = 2005652, upload-time = "2025-04-23T18:30:53.389Z" }, + { url = "https://files.pythonhosted.org/packages/d2/7a/996d8bd75f3eda405e3dd219ff5ff0a283cd8e34add39d8ef9157e722867/pydantic_core-2.33.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:efec8db3266b76ef9607c2c4c419bdb06bf335ae433b80816089ea7585816f6a", size = 2113306, upload-time = "2025-04-23T18:30:54.661Z" }, + { url = "https://files.pythonhosted.org/packages/ff/84/daf2a6fb2db40ffda6578a7e8c5a6e9c8affb251a05c233ae37098118788/pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:031c57d67ca86902726e0fae2214ce6770bbe2f710dc33063187a68744a5ecac", size = 2073720, upload-time = "2025-04-23T18:30:56.11Z" }, + { url = "https://files.pythonhosted.org/packages/77/fb/2258da019f4825128445ae79456a5499c032b55849dbd5bed78c95ccf163/pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:f8de619080e944347f5f20de29a975c2d815d9ddd8be9b9b7268e2e3ef68605a", size = 2244915, upload-time = "2025-04-23T18:30:57.501Z" }, + { url = "https://files.pythonhosted.org/packages/d8/7a/925ff73756031289468326e355b6fa8316960d0d65f8b5d6b3a3e7866de7/pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:73662edf539e72a9440129f231ed3757faab89630d291b784ca99237fb94db2b", size = 2241884, upload-time = "2025-04-23T18:30:58.867Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b0/249ee6d2646f1cdadcb813805fe76265745c4010cf20a8eba7b0e639d9b2/pydantic_core-2.33.2-cp310-cp310-win32.whl", hash = "sha256:0a39979dcbb70998b0e505fb1556a1d550a0781463ce84ebf915ba293ccb7e22", size = 1910496, upload-time = "2025-04-23T18:31:00.078Z" }, + { url = "https://files.pythonhosted.org/packages/66/ff/172ba8f12a42d4b552917aa65d1f2328990d3ccfc01d5b7c943ec084299f/pydantic_core-2.33.2-cp310-cp310-win_amd64.whl", hash = "sha256:b0379a2b24882fef529ec3b4987cb5d003b9cda32256024e6fe1586ac45fc640", size = 1955019, upload-time = "2025-04-23T18:31:01.335Z" }, + { url = "https://files.pythonhosted.org/packages/3f/8d/71db63483d518cbbf290261a1fc2839d17ff89fce7089e08cad07ccfce67/pydantic_core-2.33.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:4c5b0a576fb381edd6d27f0a85915c6daf2f8138dc5c267a57c08a62900758c7", size = 2028584, upload-time = "2025-04-23T18:31:03.106Z" }, + { url = "https://files.pythonhosted.org/packages/24/2f/3cfa7244ae292dd850989f328722d2aef313f74ffc471184dc509e1e4e5a/pydantic_core-2.33.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e799c050df38a639db758c617ec771fd8fb7a5f8eaaa4b27b101f266b216a246", size = 1855071, upload-time = "2025-04-23T18:31:04.621Z" }, + { url = "https://files.pythonhosted.org/packages/b3/d3/4ae42d33f5e3f50dd467761304be2fa0a9417fbf09735bc2cce003480f2a/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dc46a01bf8d62f227d5ecee74178ffc448ff4e5197c756331f71efcc66dc980f", size = 1897823, upload-time = "2025-04-23T18:31:06.377Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f3/aa5976e8352b7695ff808599794b1fba2a9ae2ee954a3426855935799488/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a144d4f717285c6d9234a66778059f33a89096dfb9b39117663fd8413d582dcc", size = 1983792, upload-time = "2025-04-23T18:31:07.93Z" }, + { url = "https://files.pythonhosted.org/packages/d5/7a/cda9b5a23c552037717f2b2a5257e9b2bfe45e687386df9591eff7b46d28/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:73cf6373c21bc80b2e0dc88444f41ae60b2f070ed02095754eb5a01df12256de", size = 2136338, upload-time = "2025-04-23T18:31:09.283Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/b8f9ec8dd1417eb9da784e91e1667d58a2a4a7b7b34cf4af765ef663a7e5/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3dc625f4aa79713512d1976fe9f0bc99f706a9dee21dfd1810b4bbbf228d0e8a", size = 2730998, upload-time = "2025-04-23T18:31:11.7Z" }, + { url = "https://files.pythonhosted.org/packages/47/bc/cd720e078576bdb8255d5032c5d63ee5c0bf4b7173dd955185a1d658c456/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:881b21b5549499972441da4758d662aeea93f1923f953e9cbaff14b8b9565aef", size = 2003200, upload-time = "2025-04-23T18:31:13.536Z" }, + { url = "https://files.pythonhosted.org/packages/ca/22/3602b895ee2cd29d11a2b349372446ae9727c32e78a94b3d588a40fdf187/pydantic_core-2.33.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bdc25f3681f7b78572699569514036afe3c243bc3059d3942624e936ec93450e", size = 2113890, upload-time = "2025-04-23T18:31:15.011Z" }, + { url = "https://files.pythonhosted.org/packages/ff/e6/e3c5908c03cf00d629eb38393a98fccc38ee0ce8ecce32f69fc7d7b558a7/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:fe5b32187cbc0c862ee201ad66c30cf218e5ed468ec8dc1cf49dec66e160cc4d", size = 2073359, upload-time = "2025-04-23T18:31:16.393Z" }, + { url = "https://files.pythonhosted.org/packages/12/e7/6a36a07c59ebefc8777d1ffdaf5ae71b06b21952582e4b07eba88a421c79/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:bc7aee6f634a6f4a95676fcb5d6559a2c2a390330098dba5e5a5f28a2e4ada30", size = 2245883, upload-time = "2025-04-23T18:31:17.892Z" }, + { url = "https://files.pythonhosted.org/packages/16/3f/59b3187aaa6cc0c1e6616e8045b284de2b6a87b027cce2ffcea073adf1d2/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:235f45e5dbcccf6bd99f9f472858849f73d11120d76ea8707115415f8e5ebebf", size = 2241074, upload-time = "2025-04-23T18:31:19.205Z" }, + { url = "https://files.pythonhosted.org/packages/e0/ed/55532bb88f674d5d8f67ab121a2a13c385df382de2a1677f30ad385f7438/pydantic_core-2.33.2-cp311-cp311-win32.whl", hash = "sha256:6368900c2d3ef09b69cb0b913f9f8263b03786e5b2a387706c5afb66800efd51", size = 1910538, upload-time = "2025-04-23T18:31:20.541Z" }, + { url = "https://files.pythonhosted.org/packages/fe/1b/25b7cccd4519c0b23c2dd636ad39d381abf113085ce4f7bec2b0dc755eb1/pydantic_core-2.33.2-cp311-cp311-win_amd64.whl", hash = "sha256:1e063337ef9e9820c77acc768546325ebe04ee38b08703244c1309cccc4f1bab", size = 1952909, upload-time = "2025-04-23T18:31:22.371Z" }, + { url = "https://files.pythonhosted.org/packages/49/a9/d809358e49126438055884c4366a1f6227f0f84f635a9014e2deb9b9de54/pydantic_core-2.33.2-cp311-cp311-win_arm64.whl", hash = "sha256:6b99022f1d19bc32a4c2a0d544fc9a76e3be90f0b3f4af413f87d38749300e65", size = 1897786, upload-time = "2025-04-23T18:31:24.161Z" }, + { url = "https://files.pythonhosted.org/packages/18/8a/2b41c97f554ec8c71f2a8a5f85cb56a8b0956addfe8b0efb5b3d77e8bdc3/pydantic_core-2.33.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a7ec89dc587667f22b6a0b6579c249fca9026ce7c333fc142ba42411fa243cdc", size = 2009000, upload-time = "2025-04-23T18:31:25.863Z" }, + { url = "https://files.pythonhosted.org/packages/a1/02/6224312aacb3c8ecbaa959897af57181fb6cf3a3d7917fd44d0f2917e6f2/pydantic_core-2.33.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3c6db6e52c6d70aa0d00d45cdb9b40f0433b96380071ea80b09277dba021ddf7", size = 1847996, upload-time = "2025-04-23T18:31:27.341Z" }, + { url = "https://files.pythonhosted.org/packages/d6/46/6dcdf084a523dbe0a0be59d054734b86a981726f221f4562aed313dbcb49/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e61206137cbc65e6d5256e1166f88331d3b6238e082d9f74613b9b765fb9025", size = 1880957, upload-time = "2025-04-23T18:31:28.956Z" }, + { url = "https://files.pythonhosted.org/packages/ec/6b/1ec2c03837ac00886ba8160ce041ce4e325b41d06a034adbef11339ae422/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb8c529b2819c37140eb51b914153063d27ed88e3bdc31b71198a198e921e011", size = 1964199, upload-time = "2025-04-23T18:31:31.025Z" }, + { url = "https://files.pythonhosted.org/packages/2d/1d/6bf34d6adb9debd9136bd197ca72642203ce9aaaa85cfcbfcf20f9696e83/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c52b02ad8b4e2cf14ca7b3d918f3eb0ee91e63b3167c32591e57c4317e134f8f", size = 2120296, upload-time = "2025-04-23T18:31:32.514Z" }, + { url = "https://files.pythonhosted.org/packages/e0/94/2bd0aaf5a591e974b32a9f7123f16637776c304471a0ab33cf263cf5591a/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:96081f1605125ba0855dfda83f6f3df5ec90c61195421ba72223de35ccfb2f88", size = 2676109, upload-time = "2025-04-23T18:31:33.958Z" }, + { url = "https://files.pythonhosted.org/packages/f9/41/4b043778cf9c4285d59742281a769eac371b9e47e35f98ad321349cc5d61/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1", size = 2002028, upload-time = "2025-04-23T18:31:39.095Z" }, + { url = "https://files.pythonhosted.org/packages/cb/d5/7bb781bf2748ce3d03af04d5c969fa1308880e1dca35a9bd94e1a96a922e/pydantic_core-2.33.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:572c7e6c8bb4774d2ac88929e3d1f12bc45714ae5ee6d9a788a9fb35e60bb04b", size = 2100044, upload-time = "2025-04-23T18:31:41.034Z" }, + { url = "https://files.pythonhosted.org/packages/fe/36/def5e53e1eb0ad896785702a5bbfd25eed546cdcf4087ad285021a90ed53/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:db4b41f9bd95fbe5acd76d89920336ba96f03e149097365afe1cb092fceb89a1", size = 2058881, upload-time = "2025-04-23T18:31:42.757Z" }, + { url = "https://files.pythonhosted.org/packages/01/6c/57f8d70b2ee57fc3dc8b9610315949837fa8c11d86927b9bb044f8705419/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:fa854f5cf7e33842a892e5c73f45327760bc7bc516339fda888c75ae60edaeb6", size = 2227034, upload-time = "2025-04-23T18:31:44.304Z" }, + { url = "https://files.pythonhosted.org/packages/27/b9/9c17f0396a82b3d5cbea4c24d742083422639e7bb1d5bf600e12cb176a13/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5f483cfb75ff703095c59e365360cb73e00185e01aaea067cd19acffd2ab20ea", size = 2234187, upload-time = "2025-04-23T18:31:45.891Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6a/adf5734ffd52bf86d865093ad70b2ce543415e0e356f6cacabbc0d9ad910/pydantic_core-2.33.2-cp312-cp312-win32.whl", hash = "sha256:9cb1da0f5a471435a7bc7e439b8a728e8b61e59784b2af70d7c169f8dd8ae290", size = 1892628, upload-time = "2025-04-23T18:31:47.819Z" }, + { url = "https://files.pythonhosted.org/packages/43/e4/5479fecb3606c1368d496a825d8411e126133c41224c1e7238be58b87d7e/pydantic_core-2.33.2-cp312-cp312-win_amd64.whl", hash = "sha256:f941635f2a3d96b2973e867144fde513665c87f13fe0e193c158ac51bfaaa7b2", size = 1955866, upload-time = "2025-04-23T18:31:49.635Z" }, + { url = "https://files.pythonhosted.org/packages/0d/24/8b11e8b3e2be9dd82df4b11408a67c61bb4dc4f8e11b5b0fc888b38118b5/pydantic_core-2.33.2-cp312-cp312-win_arm64.whl", hash = "sha256:cca3868ddfaccfbc4bfb1d608e2ccaaebe0ae628e1416aeb9c4d88c001bb45ab", size = 1888894, upload-time = "2025-04-23T18:31:51.609Z" }, + { url = "https://files.pythonhosted.org/packages/46/8c/99040727b41f56616573a28771b1bfa08a3d3fe74d3d513f01251f79f172/pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f", size = 2015688, upload-time = "2025-04-23T18:31:53.175Z" }, + { url = "https://files.pythonhosted.org/packages/3a/cc/5999d1eb705a6cefc31f0b4a90e9f7fc400539b1a1030529700cc1b51838/pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6", size = 1844808, upload-time = "2025-04-23T18:31:54.79Z" }, + { url = "https://files.pythonhosted.org/packages/6f/5e/a0a7b8885c98889a18b6e376f344da1ef323d270b44edf8174d6bce4d622/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef", size = 1885580, upload-time = "2025-04-23T18:31:57.393Z" }, + { url = "https://files.pythonhosted.org/packages/3b/2a/953581f343c7d11a304581156618c3f592435523dd9d79865903272c256a/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a", size = 1973859, upload-time = "2025-04-23T18:31:59.065Z" }, + { url = "https://files.pythonhosted.org/packages/e6/55/f1a813904771c03a3f97f676c62cca0c0a4138654107c1b61f19c644868b/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916", size = 2120810, upload-time = "2025-04-23T18:32:00.78Z" }, + { url = "https://files.pythonhosted.org/packages/aa/c3/053389835a996e18853ba107a63caae0b9deb4a276c6b472931ea9ae6e48/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a", size = 2676498, upload-time = "2025-04-23T18:32:02.418Z" }, + { url = "https://files.pythonhosted.org/packages/eb/3c/f4abd740877a35abade05e437245b192f9d0ffb48bbbbd708df33d3cda37/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d", size = 2000611, upload-time = "2025-04-23T18:32:04.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/a7/63ef2fed1837d1121a894d0ce88439fe3e3b3e48c7543b2a4479eb99c2bd/pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56", size = 2107924, upload-time = "2025-04-23T18:32:06.129Z" }, + { url = "https://files.pythonhosted.org/packages/04/8f/2551964ef045669801675f1cfc3b0d74147f4901c3ffa42be2ddb1f0efc4/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5", size = 2063196, upload-time = "2025-04-23T18:32:08.178Z" }, + { url = "https://files.pythonhosted.org/packages/26/bd/d9602777e77fc6dbb0c7db9ad356e9a985825547dce5ad1d30ee04903918/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e", size = 2236389, upload-time = "2025-04-23T18:32:10.242Z" }, + { url = "https://files.pythonhosted.org/packages/42/db/0e950daa7e2230423ab342ae918a794964b053bec24ba8af013fc7c94846/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162", size = 2239223, upload-time = "2025-04-23T18:32:12.382Z" }, + { url = "https://files.pythonhosted.org/packages/58/4d/4f937099c545a8a17eb52cb67fe0447fd9a373b348ccfa9a87f141eeb00f/pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849", size = 1900473, upload-time = "2025-04-23T18:32:14.034Z" }, + { url = "https://files.pythonhosted.org/packages/a0/75/4a0a9bac998d78d889def5e4ef2b065acba8cae8c93696906c3a91f310ca/pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9", size = 1955269, upload-time = "2025-04-23T18:32:15.783Z" }, + { url = "https://files.pythonhosted.org/packages/f9/86/1beda0576969592f1497b4ce8e7bc8cbdf614c352426271b1b10d5f0aa64/pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9", size = 1893921, upload-time = "2025-04-23T18:32:18.473Z" }, + { url = "https://files.pythonhosted.org/packages/a4/7d/e09391c2eebeab681df2b74bfe6c43422fffede8dc74187b2b0bf6fd7571/pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac", size = 1806162, upload-time = "2025-04-23T18:32:20.188Z" }, + { url = "https://files.pythonhosted.org/packages/f1/3d/847b6b1fed9f8ed3bb95a9ad04fbd0b212e832d4f0f50ff4d9ee5a9f15cf/pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5", size = 1981560, upload-time = "2025-04-23T18:32:22.354Z" }, + { url = "https://files.pythonhosted.org/packages/6f/9a/e73262f6c6656262b5fdd723ad90f518f579b7bc8622e43a942eec53c938/pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9", size = 1935777, upload-time = "2025-04-23T18:32:25.088Z" }, + { url = "https://files.pythonhosted.org/packages/30/68/373d55e58b7e83ce371691f6eaa7175e3a24b956c44628eb25d7da007917/pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5c4aa4e82353f65e548c476b37e64189783aa5384903bfea4f41580f255fddfa", size = 2023982, upload-time = "2025-04-23T18:32:53.14Z" }, + { url = "https://files.pythonhosted.org/packages/a4/16/145f54ac08c96a63d8ed6442f9dec17b2773d19920b627b18d4f10a061ea/pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d946c8bf0d5c24bf4fe333af284c59a19358aa3ec18cb3dc4370080da1e8ad29", size = 1858412, upload-time = "2025-04-23T18:32:55.52Z" }, + { url = "https://files.pythonhosted.org/packages/41/b1/c6dc6c3e2de4516c0bb2c46f6a373b91b5660312342a0cf5826e38ad82fa/pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:87b31b6846e361ef83fedb187bb5b4372d0da3f7e28d85415efa92d6125d6e6d", size = 1892749, upload-time = "2025-04-23T18:32:57.546Z" }, + { url = "https://files.pythonhosted.org/packages/12/73/8cd57e20afba760b21b742106f9dbdfa6697f1570b189c7457a1af4cd8a0/pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa9d91b338f2df0508606f7009fde642391425189bba6d8c653afd80fd6bb64e", size = 2067527, upload-time = "2025-04-23T18:32:59.771Z" }, + { url = "https://files.pythonhosted.org/packages/e3/d5/0bb5d988cc019b3cba4a78f2d4b3854427fc47ee8ec8e9eaabf787da239c/pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2058a32994f1fde4ca0480ab9d1e75a0e8c87c22b53a3ae66554f9af78f2fe8c", size = 2108225, upload-time = "2025-04-23T18:33:04.51Z" }, + { url = "https://files.pythonhosted.org/packages/f1/c5/00c02d1571913d496aabf146106ad8239dc132485ee22efe08085084ff7c/pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:0e03262ab796d986f978f79c943fc5f620381be7287148b8010b4097f79a39ec", size = 2069490, upload-time = "2025-04-23T18:33:06.391Z" }, + { url = "https://files.pythonhosted.org/packages/22/a8/dccc38768274d3ed3a59b5d06f59ccb845778687652daa71df0cab4040d7/pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:1a8695a8d00c73e50bff9dfda4d540b7dee29ff9b8053e38380426a85ef10052", size = 2237525, upload-time = "2025-04-23T18:33:08.44Z" }, + { url = "https://files.pythonhosted.org/packages/d4/e7/4f98c0b125dda7cf7ccd14ba936218397b44f50a56dd8c16a3091df116c3/pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:fa754d1850735a0b0e03bcffd9d4b4343eb417e47196e4485d9cca326073a42c", size = 2238446, upload-time = "2025-04-23T18:33:10.313Z" }, + { url = "https://files.pythonhosted.org/packages/ce/91/2ec36480fdb0b783cd9ef6795753c1dea13882f2e68e73bce76ae8c21e6a/pydantic_core-2.33.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:a11c8d26a50bfab49002947d3d237abe4d9e4b5bdc8846a63537b6488e197808", size = 2066678, upload-time = "2025-04-23T18:33:12.224Z" }, + { url = "https://files.pythonhosted.org/packages/7b/27/d4ae6487d73948d6f20dddcd94be4ea43e74349b56eba82e9bdee2d7494c/pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:dd14041875d09cc0f9308e37a6f8b65f5585cf2598a53aa0123df8b129d481f8", size = 2025200, upload-time = "2025-04-23T18:33:14.199Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b8/b3cb95375f05d33801024079b9392a5ab45267a63400bf1866e7ce0f0de4/pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d87c561733f66531dced0da6e864f44ebf89a8fba55f31407b00c2f7f9449593", size = 1859123, upload-time = "2025-04-23T18:33:16.555Z" }, + { url = "https://files.pythonhosted.org/packages/05/bc/0d0b5adeda59a261cd30a1235a445bf55c7e46ae44aea28f7bd6ed46e091/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f82865531efd18d6e07a04a17331af02cb7a651583c418df8266f17a63c6612", size = 1892852, upload-time = "2025-04-23T18:33:18.513Z" }, + { url = "https://files.pythonhosted.org/packages/3e/11/d37bdebbda2e449cb3f519f6ce950927b56d62f0b84fd9cb9e372a26a3d5/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bfb5112df54209d820d7bf9317c7a6c9025ea52e49f46b6a2060104bba37de7", size = 2067484, upload-time = "2025-04-23T18:33:20.475Z" }, + { url = "https://files.pythonhosted.org/packages/8c/55/1f95f0a05ce72ecb02a8a8a1c3be0579bbc29b1d5ab68f1378b7bebc5057/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:64632ff9d614e5eecfb495796ad51b0ed98c453e447a76bcbeeb69615079fc7e", size = 2108896, upload-time = "2025-04-23T18:33:22.501Z" }, + { url = "https://files.pythonhosted.org/packages/53/89/2b2de6c81fa131f423246a9109d7b2a375e83968ad0800d6e57d0574629b/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:f889f7a40498cc077332c7ab6b4608d296d852182211787d4f3ee377aaae66e8", size = 2069475, upload-time = "2025-04-23T18:33:24.528Z" }, + { url = "https://files.pythonhosted.org/packages/b8/e9/1f7efbe20d0b2b10f6718944b5d8ece9152390904f29a78e68d4e7961159/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:de4b83bb311557e439b9e186f733f6c645b9417c84e2eb8203f3f820a4b988bf", size = 2239013, upload-time = "2025-04-23T18:33:26.621Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b2/5309c905a93811524a49b4e031e9851a6b00ff0fb668794472ea7746b448/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:82f68293f055f51b51ea42fafc74b6aad03e70e191799430b90c13d643059ebb", size = 2238715, upload-time = "2025-04-23T18:33:28.656Z" }, + { url = "https://files.pythonhosted.org/packages/32/56/8a7ca5d2cd2cda1d245d34b1c9a942920a718082ae8e54e5f3e5a58b7add/pydantic_core-2.33.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:329467cecfb529c925cf2bbd4d60d2c509bc2fb52a20c1045bf09bb70971a9c1", size = 2066757, upload-time = "2025-04-23T18:33:30.645Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.8.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/88/82/c79424d7d8c29b994fb01d277da57b0a9b09cc03c3ff875f9bd8a86b2145/pydantic_settings-2.8.1.tar.gz", hash = "sha256:d5c663dfbe9db9d5e1c646b2e161da12f0d734d422ee56f567d0ea2cee4e8585", size = 83550, upload-time = "2025-02-27T10:10:32.338Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/53/a64f03044927dc47aafe029c42a5b7aabc38dfb813475e0e1bf71c4a59d0/pydantic_settings-2.8.1-py3-none-any.whl", hash = "sha256:81942d5ac3d905f7f3ee1a70df5dfb62d5569c12f51a5a647defc1c3d9ee2e9c", size = 30839, upload-time = "2025-02-27T10:10:30.711Z" }, +] + +[[package]] +name = "pydub" +version = "0.25.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/9a/e6bca0eed82db26562c73b5076539a4a08d3cffd19c3cc5913a3e61145fd/pydub-0.25.1.tar.gz", hash = "sha256:980a33ce9949cab2a569606b65674d748ecbca4f0796887fd6f46173a7b0d30f", size = 38326, upload-time = "2021-03-10T02:09:54.659Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a6/53/d78dc063216e62fc55f6b2eebb447f6a4b0a59f55c8406376f76bf959b08/pydub-0.25.1-py2.py3-none-any.whl", hash = "sha256:65617e33033874b59d87db603aa1ed450633288aefead953b30bded59cb599a6", size = 32327, upload-time = "2021-03-10T02:09:53.503Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7c/2d/c3338d48ea6cc0feb8446d8e6937e1408088a72a39937982cc6111d17f84/pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f", size = 4968581, upload-time = "2025-01-06T17:26:30.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293, upload-time = "2025-01-06T17:26:25.553Z" }, +] + +[[package]] +name = "pymdown-extensions" +version = "10.15" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/08/92/a7296491dbf5585b3a987f3f3fc87af0e632121ff3e490c14b5f2d2b4eb5/pymdown_extensions-10.15.tar.gz", hash = "sha256:0e5994e32155f4b03504f939e501b981d306daf7ec2aa1cd2eb6bd300784f8f7", size = 852320, upload-time = "2025-04-27T23:48:29.183Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/d1/c54e608505776ce4e7966d03358ae635cfd51dff1da6ee421c090dbc797b/pymdown_extensions-10.15-py3-none-any.whl", hash = "sha256:46e99bb272612b0de3b7e7caf6da8dd5f4ca5212c0b273feb9304e236c484e5f", size = 265845, upload-time = "2025-04-27T23:48:27.359Z" }, +] + +[[package]] +name = "pyparsing" +version = "3.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bb/22/f1129e69d94ffff626bdb5c835506b3a5b4f3d070f17ea295e12c2c6f60f/pyparsing-3.2.3.tar.gz", hash = "sha256:b9c13f1ab8b3b542f72e28f634bad4de758ab3ce4546e4301970ad6fa77c38be", size = 1088608, upload-time = "2025-03-25T05:01:28.114Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/e7/df2285f3d08fee213f2d041540fa4fc9ca6c2d44cf36d3a035bf2a8d2bcc/pyparsing-3.2.3-py3-none-any.whl", hash = "sha256:a749938e02d6fd0b59b356ca504a24982314bb090c383e3cf201c95ef7e2bfcf", size = 111120, upload-time = "2025-03-25T05:01:24.908Z" }, +] + +[[package]] +name = "pyproject-api" +version = "1.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/fd/437901c891f58a7b9096511750247535e891d2d5a5a6eefbc9386a2b41d5/pyproject_api-1.9.1.tar.gz", hash = "sha256:43c9918f49daab37e302038fc1aed54a8c7a91a9fa935d00b9a485f37e0f5335", size = 22710, upload-time = "2025-05-12T14:41:58.025Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/e6/c293c06695d4a3ab0260ef124a74ebadba5f4c511ce3a4259e976902c00b/pyproject_api-1.9.1-py3-none-any.whl", hash = "sha256:7d6238d92f8962773dd75b5f0c4a6a27cce092a14b623b811dba656f3b628948", size = 13158, upload-time = "2025-05-12T14:41:56.217Z" }, +] + +[[package]] +name = "pyreadline3" +version = "3.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/49/4cea918a08f02817aabae639e3d0ac046fef9f9180518a3ad394e22da148/pyreadline3-3.5.4.tar.gz", hash = "sha256:8d57d53039a1c75adba8e50dd3d992b28143480816187ea5efbd5c78e6c885b7", size = 99839, upload-time = "2024-09-19T02:40:10.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/dc/491b7661614ab97483abf2056be1deee4dc2490ecbf7bff9ab5cdbac86e1/pyreadline3-3.5.4-py3-none-any.whl", hash = "sha256:eaf8e6cc3c49bcccf145fc6067ba8643d1df34d604a1ec0eccbf7a18e6d3fae6", size = 83178, upload-time = "2024-09-19T02:40:08.598Z" }, +] + +[[package]] +name = "pytest" +version = "8.3.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/3c/c9d525a414d506893f0cd8a8d0de7706446213181570cdbd766691164e40/pytest-8.3.5.tar.gz", hash = "sha256:f4efe70cc14e511565ac476b57c279e12a855b11f48f212af1080ef2263d3845", size = 1450891, upload-time = "2025-03-02T12:54:54.503Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/3d/64ad57c803f1fa1e963a7946b6e0fea4a70df53c1a7fed304586539c2bac/pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820", size = 343634, upload-time = "2025-03-02T12:54:52.069Z" }, +] + +[[package]] +name = "pytest-cov" +version = "6.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", extra = ["toml"] }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/25/69/5f1e57f6c5a39f81411b550027bf72842c4567ff5fd572bed1edc9e4b5d9/pytest_cov-6.1.1.tar.gz", hash = "sha256:46935f7aaefba760e716c2ebfbe1c216240b9592966e7da99ea8292d4d3e2a0a", size = 66857, upload-time = "2025-04-05T14:07:51.592Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/28/d0/def53b4a790cfb21483016430ed828f64830dd981ebe1089971cd10cab25/pytest_cov-6.1.1-py3-none-any.whl", hash = "sha256:bddf29ed2d0ab6f4df17b4c55b0a657287db8684af9c42ea546b21b1041b3dde", size = 23841, upload-time = "2025-04-05T14:07:49.641Z" }, +] + +[[package]] +name = "pytest-mock" +version = "3.14.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/28/67172c96ba684058a4d24ffe144d64783d2a270d0af0d9e792737bddc75c/pytest_mock-3.14.1.tar.gz", hash = "sha256:159e9edac4c451ce77a5cdb9fc5d1100708d2dd4ba3c3df572f14097351af80e", size = 33241, upload-time = "2025-05-26T13:58:45.167Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/05/77b60e520511c53d1c1ca75f1930c7dd8e971d0c4379b7f4b3f9644685ba/pytest_mock-3.14.1-py3-none-any.whl", hash = "sha256:178aefcd11307d874b4cd3100344e7e2d888d9791a6a1d9bfe90fbc1b74fd1d0", size = 9923, upload-time = "2025-05-26T13:58:43.487Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/88/2c/7bb1416c5620485aa793f2de31d3df393d3686aa8a8506d11e10e13c5baf/python_dotenv-1.1.0.tar.gz", hash = "sha256:41f90bc6f5f177fb41f53e87666db362025010eb28f60a01c9143bfa33a2b2d5", size = 39920, upload-time = "2025-03-25T10:14:56.835Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/18/98a99ad95133c6a6e2005fe89faedf294a748bd5dc803008059409ac9b1e/python_dotenv-1.1.0-py3-none-any.whl", hash = "sha256:d7c01d9e2293916c18baf562d95698754b0dbbb5e74d457c45d4f6561fb9d55d", size = 20256, upload-time = "2025-03-25T10:14:55.034Z" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/87/f44d7c9f274c7ee665a29b885ec97089ec5dc034c7f3fafa03da9e39a09e/python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13", size = 37158, upload-time = "2024-12-16T19:45:46.972Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546, upload-time = "2024-12-16T19:45:44.423Z" }, +] + +[[package]] +name = "pytz" +version = "2025.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f8/bf/abbd3cdfb8fbc7fb3d4d38d320f2441b1e7cbe29be4f23797b4a2b5d8aac/pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3", size = 320884, upload-time = "2025-03-25T02:25:00.538Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00", size = 509225, upload-time = "2025-03-25T02:24:58.468Z" }, +] + +[[package]] +name = "pywin32" +version = "310" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/da/a5f38fffbba2fb99aa4aa905480ac4b8e83ca486659ac8c95bce47fb5276/pywin32-310-cp310-cp310-win32.whl", hash = "sha256:6dd97011efc8bf51d6793a82292419eba2c71cf8e7250cfac03bba284454abc1", size = 8848240, upload-time = "2025-03-17T00:55:46.783Z" }, + { url = "https://files.pythonhosted.org/packages/aa/fe/d873a773324fa565619ba555a82c9dabd677301720f3660a731a5d07e49a/pywin32-310-cp310-cp310-win_amd64.whl", hash = "sha256:c3e78706e4229b915a0821941a84e7ef420bf2b77e08c9dae3c76fd03fd2ae3d", size = 9601854, upload-time = "2025-03-17T00:55:48.783Z" }, + { url = "https://files.pythonhosted.org/packages/3c/84/1a8e3d7a15490d28a5d816efa229ecb4999cdc51a7c30dd8914f669093b8/pywin32-310-cp310-cp310-win_arm64.whl", hash = "sha256:33babed0cf0c92a6f94cc6cc13546ab24ee13e3e800e61ed87609ab91e4c8213", size = 8522963, upload-time = "2025-03-17T00:55:50.969Z" }, + { url = "https://files.pythonhosted.org/packages/f7/b1/68aa2986129fb1011dabbe95f0136f44509afaf072b12b8f815905a39f33/pywin32-310-cp311-cp311-win32.whl", hash = "sha256:1e765f9564e83011a63321bb9d27ec456a0ed90d3732c4b2e312b855365ed8bd", size = 8784284, upload-time = "2025-03-17T00:55:53.124Z" }, + { url = "https://files.pythonhosted.org/packages/b3/bd/d1592635992dd8db5bb8ace0551bc3a769de1ac8850200cfa517e72739fb/pywin32-310-cp311-cp311-win_amd64.whl", hash = "sha256:126298077a9d7c95c53823934f000599f66ec9296b09167810eb24875f32689c", size = 9520748, upload-time = "2025-03-17T00:55:55.203Z" }, + { url = "https://files.pythonhosted.org/packages/90/b1/ac8b1ffce6603849eb45a91cf126c0fa5431f186c2e768bf56889c46f51c/pywin32-310-cp311-cp311-win_arm64.whl", hash = "sha256:19ec5fc9b1d51c4350be7bb00760ffce46e6c95eaf2f0b2f1150657b1a43c582", size = 8455941, upload-time = "2025-03-17T00:55:57.048Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ec/4fdbe47932f671d6e348474ea35ed94227fb5df56a7c30cbbb42cd396ed0/pywin32-310-cp312-cp312-win32.whl", hash = "sha256:8a75a5cc3893e83a108c05d82198880704c44bbaee4d06e442e471d3c9ea4f3d", size = 8796239, upload-time = "2025-03-17T00:55:58.807Z" }, + { url = "https://files.pythonhosted.org/packages/e3/e5/b0627f8bb84e06991bea89ad8153a9e50ace40b2e1195d68e9dff6b03d0f/pywin32-310-cp312-cp312-win_amd64.whl", hash = "sha256:bf5c397c9a9a19a6f62f3fb821fbf36cac08f03770056711f765ec1503972060", size = 9503839, upload-time = "2025-03-17T00:56:00.8Z" }, + { url = "https://files.pythonhosted.org/packages/1f/32/9ccf53748df72301a89713936645a664ec001abd35ecc8578beda593d37d/pywin32-310-cp312-cp312-win_arm64.whl", hash = "sha256:2349cc906eae872d0663d4d6290d13b90621eaf78964bb1578632ff20e152966", size = 8459470, upload-time = "2025-03-17T00:56:02.601Z" }, + { url = "https://files.pythonhosted.org/packages/1c/09/9c1b978ffc4ae53999e89c19c77ba882d9fce476729f23ef55211ea1c034/pywin32-310-cp313-cp313-win32.whl", hash = "sha256:5d241a659c496ada3253cd01cfaa779b048e90ce4b2b38cd44168ad555ce74ab", size = 8794384, upload-time = "2025-03-17T00:56:04.383Z" }, + { url = "https://files.pythonhosted.org/packages/45/3c/b4640f740ffebadd5d34df35fecba0e1cfef8fde9f3e594df91c28ad9b50/pywin32-310-cp313-cp313-win_amd64.whl", hash = "sha256:667827eb3a90208ddbdcc9e860c81bde63a135710e21e4cb3348968e4bd5249e", size = 9503039, upload-time = "2025-03-17T00:56:06.207Z" }, + { url = "https://files.pythonhosted.org/packages/b4/f4/f785020090fb050e7fb6d34b780f2231f302609dc964672f72bfaeb59a28/pywin32-310-cp313-cp313-win_arm64.whl", hash = "sha256:e308f831de771482b7cf692a1f308f8fca701b2d8f9dde6cc440c7da17e47b33", size = 8458152, upload-time = "2025-03-17T00:56:07.819Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631, upload-time = "2024-08-06T20:33:50.674Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/95/a3fac87cb7158e231b5a6012e438c647e1a87f09f8e0d123acec8ab8bf71/PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086", size = 184199, upload-time = "2024-08-06T20:31:40.178Z" }, + { url = "https://files.pythonhosted.org/packages/c7/7a/68bd47624dab8fd4afbfd3c48e3b79efe09098ae941de5b58abcbadff5cb/PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf", size = 171758, upload-time = "2024-08-06T20:31:42.173Z" }, + { url = "https://files.pythonhosted.org/packages/49/ee/14c54df452143b9ee9f0f29074d7ca5516a36edb0b4cc40c3f280131656f/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237", size = 718463, upload-time = "2024-08-06T20:31:44.263Z" }, + { url = "https://files.pythonhosted.org/packages/4d/61/de363a97476e766574650d742205be468921a7b532aa2499fcd886b62530/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b", size = 719280, upload-time = "2024-08-06T20:31:50.199Z" }, + { url = "https://files.pythonhosted.org/packages/6b/4e/1523cb902fd98355e2e9ea5e5eb237cbc5f3ad5f3075fa65087aa0ecb669/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed", size = 751239, upload-time = "2024-08-06T20:31:52.292Z" }, + { url = "https://files.pythonhosted.org/packages/b7/33/5504b3a9a4464893c32f118a9cc045190a91637b119a9c881da1cf6b7a72/PyYAML-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180", size = 695802, upload-time = "2024-08-06T20:31:53.836Z" }, + { url = "https://files.pythonhosted.org/packages/5c/20/8347dcabd41ef3a3cdc4f7b7a2aff3d06598c8779faa189cdbf878b626a4/PyYAML-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68", size = 720527, upload-time = "2024-08-06T20:31:55.565Z" }, + { url = "https://files.pythonhosted.org/packages/be/aa/5afe99233fb360d0ff37377145a949ae258aaab831bde4792b32650a4378/PyYAML-6.0.2-cp310-cp310-win32.whl", hash = "sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99", size = 144052, upload-time = "2024-08-06T20:31:56.914Z" }, + { url = "https://files.pythonhosted.org/packages/b5/84/0fa4b06f6d6c958d207620fc60005e241ecedceee58931bb20138e1e5776/PyYAML-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e", size = 161774, upload-time = "2024-08-06T20:31:58.304Z" }, + { url = "https://files.pythonhosted.org/packages/f8/aa/7af4e81f7acba21a4c6be026da38fd2b872ca46226673c89a758ebdc4fd2/PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774", size = 184612, upload-time = "2024-08-06T20:32:03.408Z" }, + { url = "https://files.pythonhosted.org/packages/8b/62/b9faa998fd185f65c1371643678e4d58254add437edb764a08c5a98fb986/PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee", size = 172040, upload-time = "2024-08-06T20:32:04.926Z" }, + { url = "https://files.pythonhosted.org/packages/ad/0c/c804f5f922a9a6563bab712d8dcc70251e8af811fce4524d57c2c0fd49a4/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c", size = 736829, upload-time = "2024-08-06T20:32:06.459Z" }, + { url = "https://files.pythonhosted.org/packages/51/16/6af8d6a6b210c8e54f1406a6b9481febf9c64a3109c541567e35a49aa2e7/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317", size = 764167, upload-time = "2024-08-06T20:32:08.338Z" }, + { url = "https://files.pythonhosted.org/packages/75/e4/2c27590dfc9992f73aabbeb9241ae20220bd9452df27483b6e56d3975cc5/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85", size = 762952, upload-time = "2024-08-06T20:32:14.124Z" }, + { url = "https://files.pythonhosted.org/packages/9b/97/ecc1abf4a823f5ac61941a9c00fe501b02ac3ab0e373c3857f7d4b83e2b6/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4", size = 735301, upload-time = "2024-08-06T20:32:16.17Z" }, + { url = "https://files.pythonhosted.org/packages/45/73/0f49dacd6e82c9430e46f4a027baa4ca205e8b0a9dce1397f44edc23559d/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e", size = 756638, upload-time = "2024-08-06T20:32:18.555Z" }, + { url = "https://files.pythonhosted.org/packages/22/5f/956f0f9fc65223a58fbc14459bf34b4cc48dec52e00535c79b8db361aabd/PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5", size = 143850, upload-time = "2024-08-06T20:32:19.889Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/8da0bbe2ab9dcdd11f4f4557ccaf95c10b9811b13ecced089d43ce59c3c8/PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44", size = 161980, upload-time = "2024-08-06T20:32:21.273Z" }, + { url = "https://files.pythonhosted.org/packages/86/0c/c581167fc46d6d6d7ddcfb8c843a4de25bdd27e4466938109ca68492292c/PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab", size = 183873, upload-time = "2024-08-06T20:32:25.131Z" }, + { url = "https://files.pythonhosted.org/packages/a8/0c/38374f5bb272c051e2a69281d71cba6fdb983413e6758b84482905e29a5d/PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725", size = 173302, upload-time = "2024-08-06T20:32:26.511Z" }, + { url = "https://files.pythonhosted.org/packages/c3/93/9916574aa8c00aa06bbac729972eb1071d002b8e158bd0e83a3b9a20a1f7/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5", size = 739154, upload-time = "2024-08-06T20:32:28.363Z" }, + { url = "https://files.pythonhosted.org/packages/95/0f/b8938f1cbd09739c6da569d172531567dbcc9789e0029aa070856f123984/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425", size = 766223, upload-time = "2024-08-06T20:32:30.058Z" }, + { url = "https://files.pythonhosted.org/packages/b9/2b/614b4752f2e127db5cc206abc23a8c19678e92b23c3db30fc86ab731d3bd/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476", size = 767542, upload-time = "2024-08-06T20:32:31.881Z" }, + { url = "https://files.pythonhosted.org/packages/d4/00/dd137d5bcc7efea1836d6264f049359861cf548469d18da90cd8216cf05f/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48", size = 731164, upload-time = "2024-08-06T20:32:37.083Z" }, + { url = "https://files.pythonhosted.org/packages/c9/1f/4f998c900485e5c0ef43838363ba4a9723ac0ad73a9dc42068b12aaba4e4/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b", size = 756611, upload-time = "2024-08-06T20:32:38.898Z" }, + { url = "https://files.pythonhosted.org/packages/df/d1/f5a275fdb252768b7a11ec63585bc38d0e87c9e05668a139fea92b80634c/PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4", size = 140591, upload-time = "2024-08-06T20:32:40.241Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e8/4f648c598b17c3d06e8753d7d13d57542b30d56e6c2dedf9c331ae56312e/PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8", size = 156338, upload-time = "2024-08-06T20:32:41.93Z" }, + { url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309, upload-time = "2024-08-06T20:32:43.4Z" }, + { url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679, upload-time = "2024-08-06T20:32:44.801Z" }, + { url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428, upload-time = "2024-08-06T20:32:46.432Z" }, + { url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361, upload-time = "2024-08-06T20:32:51.188Z" }, + { url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523, upload-time = "2024-08-06T20:32:53.019Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660, upload-time = "2024-08-06T20:32:54.708Z" }, + { url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597, upload-time = "2024-08-06T20:32:56.985Z" }, + { url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527, upload-time = "2024-08-06T20:33:03.001Z" }, + { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446, upload-time = "2024-08-06T20:33:04.33Z" }, +] + +[[package]] +name = "pyyaml-env-tag" +version = "1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/2e/79c822141bfd05a853236b504869ebc6b70159afc570e1d5a20641782eaa/pyyaml_env_tag-1.1.tar.gz", hash = "sha256:2eb38b75a2d21ee0475d6d97ec19c63287a7e140231e4214969d0eac923cd7ff", size = 5737, upload-time = "2025-05-13T15:24:01.64Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/11/432f32f8097b03e3cd5fe57e88efb685d964e2e5178a48ed61e841f7fdce/pyyaml_env_tag-1.1-py3-none-any.whl", hash = "sha256:17109e1a528561e32f026364712fee1264bc2ea6715120891174ed1b980d2e04", size = 4722, upload-time = "2025-05-13T15:23:59.629Z" }, +] + +[[package]] +name = "pyzmq" +version = "26.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "implementation_name == 'pypy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/11/b9213d25230ac18a71b39b3723494e57adebe36e066397b961657b3b41c1/pyzmq-26.4.0.tar.gz", hash = "sha256:4bd13f85f80962f91a651a7356fe0472791a5f7a92f227822b5acf44795c626d", size = 278293, upload-time = "2025-04-04T12:05:44.049Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/b8/af1d814ffc3ff9730f9a970cbf216b6f078e5d251a25ef5201d7bc32a37c/pyzmq-26.4.0-cp310-cp310-macosx_10_15_universal2.whl", hash = "sha256:0329bdf83e170ac133f44a233fc651f6ed66ef8e66693b5af7d54f45d1ef5918", size = 1339238, upload-time = "2025-04-04T12:03:07.022Z" }, + { url = "https://files.pythonhosted.org/packages/ee/e4/5aafed4886c264f2ea6064601ad39c5fc4e9b6539c6ebe598a859832eeee/pyzmq-26.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:398a825d2dea96227cf6460ce0a174cf7657d6f6827807d4d1ae9d0f9ae64315", size = 672848, upload-time = "2025-04-04T12:03:08.591Z" }, + { url = "https://files.pythonhosted.org/packages/79/39/026bf49c721cb42f1ef3ae0ee3d348212a7621d2adb739ba97599b6e4d50/pyzmq-26.4.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6d52d62edc96787f5c1dfa6c6ccff9b581cfae5a70d94ec4c8da157656c73b5b", size = 911299, upload-time = "2025-04-04T12:03:10Z" }, + { url = "https://files.pythonhosted.org/packages/03/23/b41f936a9403b8f92325c823c0f264c6102a0687a99c820f1aaeb99c1def/pyzmq-26.4.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1410c3a3705db68d11eb2424d75894d41cff2f64d948ffe245dd97a9debfebf4", size = 867920, upload-time = "2025-04-04T12:03:11.311Z" }, + { url = "https://files.pythonhosted.org/packages/c1/3e/2de5928cdadc2105e7c8f890cc5f404136b41ce5b6eae5902167f1d5641c/pyzmq-26.4.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:7dacb06a9c83b007cc01e8e5277f94c95c453c5851aac5e83efe93e72226353f", size = 862514, upload-time = "2025-04-04T12:03:13.013Z" }, + { url = "https://files.pythonhosted.org/packages/ce/57/109569514dd32e05a61d4382bc88980c95bfd2f02e58fea47ec0ccd96de1/pyzmq-26.4.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:6bab961c8c9b3a4dc94d26e9b2cdf84de9918931d01d6ff38c721a83ab3c0ef5", size = 1204494, upload-time = "2025-04-04T12:03:14.795Z" }, + { url = "https://files.pythonhosted.org/packages/aa/02/dc51068ff2ca70350d1151833643a598625feac7b632372d229ceb4de3e1/pyzmq-26.4.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:7a5c09413b924d96af2aa8b57e76b9b0058284d60e2fc3730ce0f979031d162a", size = 1514525, upload-time = "2025-04-04T12:03:16.246Z" }, + { url = "https://files.pythonhosted.org/packages/48/2a/a7d81873fff0645eb60afaec2b7c78a85a377af8f1d911aff045d8955bc7/pyzmq-26.4.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:7d489ac234d38e57f458fdbd12a996bfe990ac028feaf6f3c1e81ff766513d3b", size = 1414659, upload-time = "2025-04-04T12:03:17.652Z" }, + { url = "https://files.pythonhosted.org/packages/ef/ea/813af9c42ae21845c1ccfe495bd29c067622a621e85d7cda6bc437de8101/pyzmq-26.4.0-cp310-cp310-win32.whl", hash = "sha256:dea1c8db78fb1b4b7dc9f8e213d0af3fc8ecd2c51a1d5a3ca1cde1bda034a980", size = 580348, upload-time = "2025-04-04T12:03:19.384Z" }, + { url = "https://files.pythonhosted.org/packages/20/68/318666a89a565252c81d3fed7f3b4c54bd80fd55c6095988dfa2cd04a62b/pyzmq-26.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:fa59e1f5a224b5e04dc6c101d7186058efa68288c2d714aa12d27603ae93318b", size = 643838, upload-time = "2025-04-04T12:03:20.795Z" }, + { url = "https://files.pythonhosted.org/packages/91/f8/fb1a15b5f4ecd3e588bfde40c17d32ed84b735195b5c7d1d7ce88301a16f/pyzmq-26.4.0-cp310-cp310-win_arm64.whl", hash = "sha256:a651fe2f447672f4a815e22e74630b6b1ec3a1ab670c95e5e5e28dcd4e69bbb5", size = 559565, upload-time = "2025-04-04T12:03:22.676Z" }, + { url = "https://files.pythonhosted.org/packages/32/6d/234e3b0aa82fd0290b1896e9992f56bdddf1f97266110be54d0177a9d2d9/pyzmq-26.4.0-cp311-cp311-macosx_10_15_universal2.whl", hash = "sha256:bfcf82644c9b45ddd7cd2a041f3ff8dce4a0904429b74d73a439e8cab1bd9e54", size = 1339723, upload-time = "2025-04-04T12:03:24.358Z" }, + { url = "https://files.pythonhosted.org/packages/4f/11/6d561efe29ad83f7149a7cd48e498e539ed09019c6cd7ecc73f4cc725028/pyzmq-26.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e9bcae3979b2654d5289d3490742378b2f3ce804b0b5fd42036074e2bf35b030", size = 672645, upload-time = "2025-04-04T12:03:25.693Z" }, + { url = "https://files.pythonhosted.org/packages/19/fd/81bfe3e23f418644660bad1a90f0d22f0b3eebe33dd65a79385530bceb3d/pyzmq-26.4.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ccdff8ac4246b6fb60dcf3982dfaeeff5dd04f36051fe0632748fc0aa0679c01", size = 910133, upload-time = "2025-04-04T12:03:27.625Z" }, + { url = "https://files.pythonhosted.org/packages/97/68/321b9c775595ea3df832a9516252b653fe32818db66fdc8fa31c9b9fce37/pyzmq-26.4.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4550af385b442dc2d55ab7717837812799d3674cb12f9a3aa897611839c18e9e", size = 867428, upload-time = "2025-04-04T12:03:29.004Z" }, + { url = "https://files.pythonhosted.org/packages/4e/6e/159cbf2055ef36aa2aa297e01b24523176e5b48ead283c23a94179fb2ba2/pyzmq-26.4.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:2f9f7ffe9db1187a253fca95191854b3fda24696f086e8789d1d449308a34b88", size = 862409, upload-time = "2025-04-04T12:03:31.032Z" }, + { url = "https://files.pythonhosted.org/packages/05/1c/45fb8db7be5a7d0cadea1070a9cbded5199a2d578de2208197e592f219bd/pyzmq-26.4.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:3709c9ff7ba61589b7372923fd82b99a81932b592a5c7f1a24147c91da9a68d6", size = 1205007, upload-time = "2025-04-04T12:03:32.687Z" }, + { url = "https://files.pythonhosted.org/packages/f8/fa/658c7f583af6498b463f2fa600f34e298e1b330886f82f1feba0dc2dd6c3/pyzmq-26.4.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:f8f3c30fb2d26ae5ce36b59768ba60fb72507ea9efc72f8f69fa088450cff1df", size = 1514599, upload-time = "2025-04-04T12:03:34.084Z" }, + { url = "https://files.pythonhosted.org/packages/4d/d7/44d641522353ce0a2bbd150379cb5ec32f7120944e6bfba4846586945658/pyzmq-26.4.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:382a4a48c8080e273427fc692037e3f7d2851959ffe40864f2db32646eeb3cef", size = 1414546, upload-time = "2025-04-04T12:03:35.478Z" }, + { url = "https://files.pythonhosted.org/packages/72/76/c8ed7263218b3d1e9bce07b9058502024188bd52cc0b0a267a9513b431fc/pyzmq-26.4.0-cp311-cp311-win32.whl", hash = "sha256:d56aad0517d4c09e3b4f15adebba8f6372c5102c27742a5bdbfc74a7dceb8fca", size = 579247, upload-time = "2025-04-04T12:03:36.846Z" }, + { url = "https://files.pythonhosted.org/packages/c3/d0/2d9abfa2571a0b1a67c0ada79a8aa1ba1cce57992d80f771abcdf99bb32c/pyzmq-26.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:963977ac8baed7058c1e126014f3fe58b3773f45c78cce7af5c26c09b6823896", size = 644727, upload-time = "2025-04-04T12:03:38.578Z" }, + { url = "https://files.pythonhosted.org/packages/0d/d1/c8ad82393be6ccedfc3c9f3adb07f8f3976e3c4802640fe3f71441941e70/pyzmq-26.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:c0c8e8cadc81e44cc5088fcd53b9b3b4ce9344815f6c4a03aec653509296fae3", size = 559942, upload-time = "2025-04-04T12:03:40.143Z" }, + { url = "https://files.pythonhosted.org/packages/10/44/a778555ebfdf6c7fc00816aad12d185d10a74d975800341b1bc36bad1187/pyzmq-26.4.0-cp312-cp312-macosx_10_15_universal2.whl", hash = "sha256:5227cb8da4b6f68acfd48d20c588197fd67745c278827d5238c707daf579227b", size = 1341586, upload-time = "2025-04-04T12:03:41.954Z" }, + { url = "https://files.pythonhosted.org/packages/9c/4f/f3a58dc69ac757e5103be3bd41fb78721a5e17da7cc617ddb56d973a365c/pyzmq-26.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e1c07a7fa7f7ba86554a2b1bef198c9fed570c08ee062fd2fd6a4dcacd45f905", size = 665880, upload-time = "2025-04-04T12:03:43.45Z" }, + { url = "https://files.pythonhosted.org/packages/fe/45/50230bcfb3ae5cb98bee683b6edeba1919f2565d7cc1851d3c38e2260795/pyzmq-26.4.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae775fa83f52f52de73183f7ef5395186f7105d5ed65b1ae65ba27cb1260de2b", size = 902216, upload-time = "2025-04-04T12:03:45.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/59/56bbdc5689be5e13727491ad2ba5efd7cd564365750514f9bc8f212eef82/pyzmq-26.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66c760d0226ebd52f1e6b644a9e839b5db1e107a23f2fcd46ec0569a4fdd4e63", size = 859814, upload-time = "2025-04-04T12:03:47.188Z" }, + { url = "https://files.pythonhosted.org/packages/81/b1/57db58cfc8af592ce94f40649bd1804369c05b2190e4cbc0a2dad572baeb/pyzmq-26.4.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:ef8c6ecc1d520debc147173eaa3765d53f06cd8dbe7bd377064cdbc53ab456f5", size = 855889, upload-time = "2025-04-04T12:03:49.223Z" }, + { url = "https://files.pythonhosted.org/packages/e8/92/47542e629cbac8f221c230a6d0f38dd3d9cff9f6f589ed45fdf572ffd726/pyzmq-26.4.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:3150ef4084e163dec29ae667b10d96aad309b668fac6810c9e8c27cf543d6e0b", size = 1197153, upload-time = "2025-04-04T12:03:50.591Z" }, + { url = "https://files.pythonhosted.org/packages/07/e5/b10a979d1d565d54410afc87499b16c96b4a181af46e7645ab4831b1088c/pyzmq-26.4.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:4448c9e55bf8329fa1dcedd32f661bf611214fa70c8e02fee4347bc589d39a84", size = 1507352, upload-time = "2025-04-04T12:03:52.473Z" }, + { url = "https://files.pythonhosted.org/packages/ab/58/5a23db84507ab9c01c04b1232a7a763be66e992aa2e66498521bbbc72a71/pyzmq-26.4.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:e07dde3647afb084d985310d067a3efa6efad0621ee10826f2cb2f9a31b89d2f", size = 1406834, upload-time = "2025-04-04T12:03:54Z" }, + { url = "https://files.pythonhosted.org/packages/22/74/aaa837b331580c13b79ac39396601fb361454ee184ca85e8861914769b99/pyzmq-26.4.0-cp312-cp312-win32.whl", hash = "sha256:ba034a32ecf9af72adfa5ee383ad0fd4f4e38cdb62b13624278ef768fe5b5b44", size = 577992, upload-time = "2025-04-04T12:03:55.815Z" }, + { url = "https://files.pythonhosted.org/packages/30/0f/55f8c02c182856743b82dde46b2dc3e314edda7f1098c12a8227eeda0833/pyzmq-26.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:056a97aab4064f526ecb32f4343917a4022a5d9efb6b9df990ff72e1879e40be", size = 640466, upload-time = "2025-04-04T12:03:57.231Z" }, + { url = "https://files.pythonhosted.org/packages/e4/29/073779afc3ef6f830b8de95026ef20b2d1ec22d0324d767748d806e57379/pyzmq-26.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:2f23c750e485ce1eb639dbd576d27d168595908aa2d60b149e2d9e34c9df40e0", size = 556342, upload-time = "2025-04-04T12:03:59.218Z" }, + { url = "https://files.pythonhosted.org/packages/d7/20/fb2c92542488db70f833b92893769a569458311a76474bda89dc4264bd18/pyzmq-26.4.0-cp313-cp313-macosx_10_15_universal2.whl", hash = "sha256:c43fac689880f5174d6fc864857d1247fe5cfa22b09ed058a344ca92bf5301e3", size = 1339484, upload-time = "2025-04-04T12:04:00.671Z" }, + { url = "https://files.pythonhosted.org/packages/58/29/2f06b9cabda3a6ea2c10f43e67ded3e47fc25c54822e2506dfb8325155d4/pyzmq-26.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:902aca7eba477657c5fb81c808318460328758e8367ecdd1964b6330c73cae43", size = 666106, upload-time = "2025-04-04T12:04:02.366Z" }, + { url = "https://files.pythonhosted.org/packages/77/e4/dcf62bd29e5e190bd21bfccaa4f3386e01bf40d948c239239c2f1e726729/pyzmq-26.4.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e5e48a830bfd152fe17fbdeaf99ac5271aa4122521bf0d275b6b24e52ef35eb6", size = 902056, upload-time = "2025-04-04T12:04:03.919Z" }, + { url = "https://files.pythonhosted.org/packages/1a/cf/b36b3d7aea236087d20189bec1a87eeb2b66009731d7055e5c65f845cdba/pyzmq-26.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:31be2b6de98c824c06f5574331f805707c667dc8f60cb18580b7de078479891e", size = 860148, upload-time = "2025-04-04T12:04:05.581Z" }, + { url = "https://files.pythonhosted.org/packages/18/a6/f048826bc87528c208e90604c3bf573801e54bd91e390cbd2dfa860e82dc/pyzmq-26.4.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:6332452034be001bbf3206ac59c0d2a7713de5f25bb38b06519fc6967b7cf771", size = 855983, upload-time = "2025-04-04T12:04:07.096Z" }, + { url = "https://files.pythonhosted.org/packages/0a/27/454d34ab6a1d9772a36add22f17f6b85baf7c16e14325fa29e7202ca8ee8/pyzmq-26.4.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:da8c0f5dd352136853e6a09b1b986ee5278dfddfebd30515e16eae425c872b30", size = 1197274, upload-time = "2025-04-04T12:04:08.523Z" }, + { url = "https://files.pythonhosted.org/packages/f4/3d/7abfeab6b83ad38aa34cbd57c6fc29752c391e3954fd12848bd8d2ec0df6/pyzmq-26.4.0-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:f4ccc1a0a2c9806dda2a2dd118a3b7b681e448f3bb354056cad44a65169f6d86", size = 1507120, upload-time = "2025-04-04T12:04:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/13/ff/bc8d21dbb9bc8705126e875438a1969c4f77e03fc8565d6901c7933a3d01/pyzmq-26.4.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:1c0b5fceadbab461578daf8d1dcc918ebe7ddd2952f748cf30c7cf2de5d51101", size = 1406738, upload-time = "2025-04-04T12:04:12.509Z" }, + { url = "https://files.pythonhosted.org/packages/f5/5d/d4cd85b24de71d84d81229e3bbb13392b2698432cf8fdcea5afda253d587/pyzmq-26.4.0-cp313-cp313-win32.whl", hash = "sha256:28e2b0ff5ba4b3dd11062d905682bad33385cfa3cc03e81abd7f0822263e6637", size = 577826, upload-time = "2025-04-04T12:04:14.289Z" }, + { url = "https://files.pythonhosted.org/packages/c6/6c/f289c1789d7bb6e5a3b3bef7b2a55089b8561d17132be7d960d3ff33b14e/pyzmq-26.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:23ecc9d241004c10e8b4f49d12ac064cd7000e1643343944a10df98e57bc544b", size = 640406, upload-time = "2025-04-04T12:04:15.757Z" }, + { url = "https://files.pythonhosted.org/packages/b3/99/676b8851cb955eb5236a0c1e9ec679ea5ede092bf8bf2c8a68d7e965cac3/pyzmq-26.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:1edb0385c7f025045d6e0f759d4d3afe43c17a3d898914ec6582e6f464203c08", size = 556216, upload-time = "2025-04-04T12:04:17.212Z" }, + { url = "https://files.pythonhosted.org/packages/65/c2/1fac340de9d7df71efc59d9c50fc7a635a77b103392d1842898dd023afcb/pyzmq-26.4.0-cp313-cp313t-macosx_10_15_universal2.whl", hash = "sha256:93a29e882b2ba1db86ba5dd5e88e18e0ac6b627026c5cfbec9983422011b82d4", size = 1333769, upload-time = "2025-04-04T12:04:18.665Z" }, + { url = "https://files.pythonhosted.org/packages/5c/c7/6c03637e8d742c3b00bec4f5e4cd9d1c01b2f3694c6f140742e93ca637ed/pyzmq-26.4.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cb45684f276f57110bb89e4300c00f1233ca631f08f5f42528a5c408a79efc4a", size = 658826, upload-time = "2025-04-04T12:04:20.405Z" }, + { url = "https://files.pythonhosted.org/packages/a5/97/a8dca65913c0f78e0545af2bb5078aebfc142ca7d91cdaffa1fbc73e5dbd/pyzmq-26.4.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f72073e75260cb301aad4258ad6150fa7f57c719b3f498cb91e31df16784d89b", size = 891650, upload-time = "2025-04-04T12:04:22.413Z" }, + { url = "https://files.pythonhosted.org/packages/7d/7e/f63af1031eb060bf02d033732b910fe48548dcfdbe9c785e9f74a6cc6ae4/pyzmq-26.4.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:be37e24b13026cfedd233bcbbccd8c0bcd2fdd186216094d095f60076201538d", size = 849776, upload-time = "2025-04-04T12:04:23.959Z" }, + { url = "https://files.pythonhosted.org/packages/f6/fa/1a009ce582802a895c0d5fe9413f029c940a0a8ee828657a3bb0acffd88b/pyzmq-26.4.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:237b283044934d26f1eeff4075f751b05d2f3ed42a257fc44386d00df6a270cf", size = 842516, upload-time = "2025-04-04T12:04:25.449Z" }, + { url = "https://files.pythonhosted.org/packages/6e/bc/f88b0bad0f7a7f500547d71e99f10336f2314e525d4ebf576a1ea4a1d903/pyzmq-26.4.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:b30f862f6768b17040929a68432c8a8be77780317f45a353cb17e423127d250c", size = 1189183, upload-time = "2025-04-04T12:04:27.035Z" }, + { url = "https://files.pythonhosted.org/packages/d9/8c/db446a3dd9cf894406dec2e61eeffaa3c07c3abb783deaebb9812c4af6a5/pyzmq-26.4.0-cp313-cp313t-musllinux_1_1_i686.whl", hash = "sha256:c80fcd3504232f13617c6ab501124d373e4895424e65de8b72042333316f64a8", size = 1495501, upload-time = "2025-04-04T12:04:28.833Z" }, + { url = "https://files.pythonhosted.org/packages/05/4c/bf3cad0d64c3214ac881299c4562b815f05d503bccc513e3fd4fdc6f67e4/pyzmq-26.4.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:26a2a7451606b87f67cdeca2c2789d86f605da08b4bd616b1a9981605ca3a364", size = 1395540, upload-time = "2025-04-04T12:04:30.562Z" }, + { url = "https://files.pythonhosted.org/packages/47/03/96004704a84095f493be8d2b476641f5c967b269390173f85488a53c1c13/pyzmq-26.4.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:98d948288ce893a2edc5ec3c438fe8de2daa5bbbd6e2e865ec5f966e237084ba", size = 834408, upload-time = "2025-04-04T12:05:04.569Z" }, + { url = "https://files.pythonhosted.org/packages/e4/7f/68d8f3034a20505db7551cb2260248be28ca66d537a1ac9a257913d778e4/pyzmq-26.4.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9f34f5c9e0203ece706a1003f1492a56c06c0632d86cb77bcfe77b56aacf27b", size = 569580, upload-time = "2025-04-04T12:05:06.283Z" }, + { url = "https://files.pythonhosted.org/packages/9b/a6/2b0d6801ec33f2b2a19dd8d02e0a1e8701000fec72926e6787363567d30c/pyzmq-26.4.0-pp310-pypy310_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:80c9b48aef586ff8b698359ce22f9508937c799cc1d2c9c2f7c95996f2300c94", size = 798250, upload-time = "2025-04-04T12:05:07.88Z" }, + { url = "https://files.pythonhosted.org/packages/96/2a/0322b3437de977dcac8a755d6d7ce6ec5238de78e2e2d9353730b297cf12/pyzmq-26.4.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3f2a5b74009fd50b53b26f65daff23e9853e79aa86e0aa08a53a7628d92d44a", size = 756758, upload-time = "2025-04-04T12:05:09.483Z" }, + { url = "https://files.pythonhosted.org/packages/c2/33/43704f066369416d65549ccee366cc19153911bec0154da7c6b41fca7e78/pyzmq-26.4.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:61c5f93d7622d84cb3092d7f6398ffc77654c346545313a3737e266fc11a3beb", size = 555371, upload-time = "2025-04-04T12:05:11.062Z" }, + { url = "https://files.pythonhosted.org/packages/04/52/a70fcd5592715702248306d8e1729c10742c2eac44529984413b05c68658/pyzmq-26.4.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:4478b14cb54a805088299c25a79f27eaf530564a7a4f72bf432a040042b554eb", size = 834405, upload-time = "2025-04-04T12:05:13.3Z" }, + { url = "https://files.pythonhosted.org/packages/25/f9/1a03f1accff16b3af1a6fa22cbf7ced074776abbf688b2e9cb4629700c62/pyzmq-26.4.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8a28ac29c60e4ba84b5f58605ace8ad495414a724fe7aceb7cf06cd0598d04e1", size = 569578, upload-time = "2025-04-04T12:05:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/76/0c/3a633acd762aa6655fcb71fa841907eae0ab1e8582ff494b137266de341d/pyzmq-26.4.0-pp311-pypy311_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:43b03c1ceea27c6520124f4fb2ba9c647409b9abdf9a62388117148a90419494", size = 798248, upload-time = "2025-04-04T12:05:17.376Z" }, + { url = "https://files.pythonhosted.org/packages/cd/cc/6c99c84aa60ac1cc56747bed6be8ce6305b9b861d7475772e7a25ce019d3/pyzmq-26.4.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7731abd23a782851426d4e37deb2057bf9410848a4459b5ede4fe89342e687a9", size = 756757, upload-time = "2025-04-04T12:05:19.19Z" }, + { url = "https://files.pythonhosted.org/packages/13/9c/d8073bd898eb896e94c679abe82e47506e2b750eb261cf6010ced869797c/pyzmq-26.4.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a222ad02fbe80166b0526c038776e8042cd4e5f0dec1489a006a1df47e9040e0", size = 555371, upload-time = "2025-04-04T12:05:20.702Z" }, +] + +[[package]] +name = "requests" +version = "2.32.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/63/70/2bf7780ad2d390a8d301ad0b550f1581eadbd9a20f896afe06353c2a2913/requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", size = 131218, upload-time = "2024-05-29T15:37:49.536Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928, upload-time = "2024-05-29T15:37:47.027Z" }, +] + +[[package]] +name = "rich" +version = "14.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/53/830aa4c3066a8ab0ae9a9955976fb770fe9c6102117c8ec4ab3ea62d89e8/rich-14.0.0.tar.gz", hash = "sha256:82f1bc23a6a21ebca4ae0c45af9bdbc492ed20231dcb63f297d6d1021a9d5725", size = 224078, upload-time = "2025-03-30T14:15:14.23Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/9b/63f4c7ebc259242c89b3acafdb37b41d1185c07ff0011164674e9076b491/rich-14.0.0-py3-none-any.whl", hash = "sha256:1c9491e1951aac09caffd42f448ee3d04e58923ffe14993f6e83068dc395d7e0", size = 243229, upload-time = "2025-03-30T14:15:12.283Z" }, +] + +[[package]] +name = "ruff" +version = "0.11.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/53/ae4857030d59286924a8bdb30d213d6ff22d8f0957e738d0289990091dd8/ruff-0.11.11.tar.gz", hash = "sha256:7774173cc7c1980e6bf67569ebb7085989a78a103922fb83ef3dfe230cd0687d", size = 4186707, upload-time = "2025-05-22T19:19:34.363Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b1/14/f2326676197bab099e2a24473158c21656fbf6a207c65f596ae15acb32b9/ruff-0.11.11-py3-none-linux_armv6l.whl", hash = "sha256:9924e5ae54125ed8958a4f7de320dab7380f6e9fa3195e3dc3b137c6842a0092", size = 10229049, upload-time = "2025-05-22T19:18:45.516Z" }, + { url = "https://files.pythonhosted.org/packages/9a/f3/bff7c92dd66c959e711688b2e0768e486bbca46b2f35ac319bb6cce04447/ruff-0.11.11-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:c8a93276393d91e952f790148eb226658dd275cddfde96c6ca304873f11d2ae4", size = 11053601, upload-time = "2025-05-22T19:18:49.269Z" }, + { url = "https://files.pythonhosted.org/packages/e2/38/8e1a3efd0ef9d8259346f986b77de0f62c7a5ff4a76563b6b39b68f793b9/ruff-0.11.11-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d6e333dbe2e6ae84cdedefa943dfd6434753ad321764fd937eef9d6b62022bcd", size = 10367421, upload-time = "2025-05-22T19:18:51.754Z" }, + { url = "https://files.pythonhosted.org/packages/b4/50/557ad9dd4fb9d0bf524ec83a090a3932d284d1a8b48b5906b13b72800e5f/ruff-0.11.11-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7885d9a5e4c77b24e8c88aba8c80be9255fa22ab326019dac2356cff42089fc6", size = 10581980, upload-time = "2025-05-22T19:18:54.011Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b2/e2ed82d6e2739ece94f1bdbbd1d81b712d3cdaf69f0a1d1f1a116b33f9ad/ruff-0.11.11-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1b5ab797fcc09121ed82e9b12b6f27e34859e4227080a42d090881be888755d4", size = 10089241, upload-time = "2025-05-22T19:18:56.041Z" }, + { url = "https://files.pythonhosted.org/packages/3d/9f/b4539f037a5302c450d7c695c82f80e98e48d0d667ecc250e6bdeb49b5c3/ruff-0.11.11-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e231ff3132c1119ece836487a02785f099a43992b95c2f62847d29bace3c75ac", size = 11699398, upload-time = "2025-05-22T19:18:58.248Z" }, + { url = "https://files.pythonhosted.org/packages/61/fb/32e029d2c0b17df65e6eaa5ce7aea5fbeaed22dddd9fcfbbf5fe37c6e44e/ruff-0.11.11-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:a97c9babe1d4081037a90289986925726b802d180cca784ac8da2bbbc335f709", size = 12427955, upload-time = "2025-05-22T19:19:00.981Z" }, + { url = "https://files.pythonhosted.org/packages/6e/e3/160488dbb11f18c8121cfd588e38095ba779ae208292765972f7732bfd95/ruff-0.11.11-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d8c4ddcbe8a19f59f57fd814b8b117d4fcea9bee7c0492e6cf5fdc22cfa563c8", size = 12069803, upload-time = "2025-05-22T19:19:03.258Z" }, + { url = "https://files.pythonhosted.org/packages/ff/16/3b006a875f84b3d0bff24bef26b8b3591454903f6f754b3f0a318589dcc3/ruff-0.11.11-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6224076c344a7694c6fbbb70d4f2a7b730f6d47d2a9dc1e7f9d9bb583faf390b", size = 11242630, upload-time = "2025-05-22T19:19:05.871Z" }, + { url = "https://files.pythonhosted.org/packages/65/0d/0338bb8ac0b97175c2d533e9c8cdc127166de7eb16d028a43c5ab9e75abd/ruff-0.11.11-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:882821fcdf7ae8db7a951df1903d9cb032bbe838852e5fc3c2b6c3ab54e39875", size = 11507310, upload-time = "2025-05-22T19:19:08.584Z" }, + { url = "https://files.pythonhosted.org/packages/6f/bf/d7130eb26174ce9b02348b9f86d5874eafbf9f68e5152e15e8e0a392e4a3/ruff-0.11.11-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:dcec2d50756463d9df075a26a85a6affbc1b0148873da3997286caf1ce03cae1", size = 10441144, upload-time = "2025-05-22T19:19:13.621Z" }, + { url = "https://files.pythonhosted.org/packages/b3/f3/4be2453b258c092ff7b1761987cf0749e70ca1340cd1bfb4def08a70e8d8/ruff-0.11.11-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:99c28505ecbaeb6594701a74e395b187ee083ee26478c1a795d35084d53ebd81", size = 10081987, upload-time = "2025-05-22T19:19:15.821Z" }, + { url = "https://files.pythonhosted.org/packages/6c/6e/dfa4d2030c5b5c13db158219f2ec67bf333e8a7748dccf34cfa2a6ab9ebc/ruff-0.11.11-py3-none-musllinux_1_2_i686.whl", hash = "sha256:9263f9e5aa4ff1dec765e99810f1cc53f0c868c5329b69f13845f699fe74f639", size = 11073922, upload-time = "2025-05-22T19:19:18.104Z" }, + { url = "https://files.pythonhosted.org/packages/ff/f4/f7b0b0c3d32b593a20ed8010fa2c1a01f2ce91e79dda6119fcc51d26c67b/ruff-0.11.11-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:64ac6f885e3ecb2fdbb71de2701d4e34526651f1e8503af8fb30d4915a3fe345", size = 11568537, upload-time = "2025-05-22T19:19:20.889Z" }, + { url = "https://files.pythonhosted.org/packages/d2/46/0e892064d0adc18bcc81deed9aaa9942a27fd2cd9b1b7791111ce468c25f/ruff-0.11.11-py3-none-win32.whl", hash = "sha256:1adcb9a18802268aaa891ffb67b1c94cd70578f126637118e8099b8e4adcf112", size = 10536492, upload-time = "2025-05-22T19:19:23.642Z" }, + { url = "https://files.pythonhosted.org/packages/1b/d9/232e79459850b9f327e9f1dc9c047a2a38a6f9689e1ec30024841fc4416c/ruff-0.11.11-py3-none-win_amd64.whl", hash = "sha256:748b4bb245f11e91a04a4ff0f96e386711df0a30412b9fe0c74d5bdc0e4a531f", size = 11612562, upload-time = "2025-05-22T19:19:27.013Z" }, + { url = "https://files.pythonhosted.org/packages/ce/eb/09c132cff3cc30b2e7244191dcce69437352d6d6709c0adf374f3e6f476e/ruff-0.11.11-py3-none-win_arm64.whl", hash = "sha256:6c51f136c0364ab1b774767aa8b86331bd8e9d414e2d107db7a2189f35ea1f7b", size = 10735951, upload-time = "2025-05-22T19:19:30.043Z" }, +] + +[[package]] +name = "safehttpx" +version = "0.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/67/4c/19db75e6405692b2a96af8f06d1258f8aa7290bdc35ac966f03e207f6d7f/safehttpx-0.1.6.tar.gz", hash = "sha256:b356bfc82cee3a24c395b94a2dbeabbed60aff1aa5fa3b5fe97c4f2456ebce42", size = 9987, upload-time = "2024-12-02T18:44:10.226Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/c0/1108ad9f01567f66b3154063605b350b69c3c9366732e09e45f9fd0d1deb/safehttpx-0.1.6-py3-none-any.whl", hash = "sha256:407cff0b410b071623087c63dd2080c3b44dc076888d8c5823c00d1e58cb381c", size = 8692, upload-time = "2024-12-02T18:44:08.555Z" }, +] + +[[package]] +name = "scipy" +version = "1.14.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/62/11/4d44a1f274e002784e4dbdb81e0ea96d2de2d1045b2132d5af62cc31fd28/scipy-1.14.1.tar.gz", hash = "sha256:5a275584e726026a5699459aa72f828a610821006228e841b94275c4a7c08417", size = 58620554, upload-time = "2024-08-21T00:09:20.662Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/68/3bc0cfaf64ff507d82b1e5d5b64521df4c8bf7e22bc0b897827cbee9872c/scipy-1.14.1-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:b28d2ca4add7ac16ae8bb6632a3c86e4b9e4d52d3e34267f6e1b0c1f8d87e389", size = 39069598, upload-time = "2024-08-21T00:03:32.896Z" }, + { url = "https://files.pythonhosted.org/packages/43/a5/8d02f9c372790326ad405d94f04d4339482ec082455b9e6e288f7100513b/scipy-1.14.1-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:d0d2821003174de06b69e58cef2316a6622b60ee613121199cb2852a873f8cf3", size = 29879676, upload-time = "2024-08-21T00:03:38.844Z" }, + { url = "https://files.pythonhosted.org/packages/07/42/0e0bea9666fcbf2cb6ea0205db42c81b1f34d7b729ba251010edf9c80ebd/scipy-1.14.1-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:8bddf15838ba768bb5f5083c1ea012d64c9a444e16192762bd858f1e126196d0", size = 23088696, upload-time = "2024-08-21T00:03:43.583Z" }, + { url = "https://files.pythonhosted.org/packages/15/47/298ab6fef5ebf31b426560e978b8b8548421d4ed0bf99263e1eb44532306/scipy-1.14.1-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:97c5dddd5932bd2a1a31c927ba5e1463a53b87ca96b5c9bdf5dfd6096e27efc3", size = 25470699, upload-time = "2024-08-21T00:03:48.466Z" }, + { url = "https://files.pythonhosted.org/packages/d8/df/cdb6be5274bc694c4c22862ac3438cb04f360ed9df0aecee02ce0b798380/scipy-1.14.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2ff0a7e01e422c15739ecd64432743cf7aae2b03f3084288f399affcefe5222d", size = 35606631, upload-time = "2024-08-21T00:03:54.532Z" }, + { url = "https://files.pythonhosted.org/packages/47/78/b0c2c23880dd1e99e938ad49ccfb011ae353758a2dc5ed7ee59baff684c3/scipy-1.14.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e32dced201274bf96899e6491d9ba3e9a5f6b336708656466ad0522d8528f69", size = 41178528, upload-time = "2024-08-21T00:04:00.862Z" }, + { url = "https://files.pythonhosted.org/packages/5d/aa/994b45c34b897637b853ec04334afa55a85650a0d11dacfa67232260fb0a/scipy-1.14.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8426251ad1e4ad903a4514712d2fa8fdd5382c978010d1c6f5f37ef286a713ad", size = 42784535, upload-time = "2024-08-21T00:04:12.65Z" }, + { url = "https://files.pythonhosted.org/packages/e7/1c/8daa6df17a945cb1a2a1e3bae3c49643f7b3b94017ff01a4787064f03f84/scipy-1.14.1-cp310-cp310-win_amd64.whl", hash = "sha256:a49f6ed96f83966f576b33a44257d869756df6cf1ef4934f59dd58b25e0327e5", size = 44772117, upload-time = "2024-08-21T00:04:20.613Z" }, + { url = "https://files.pythonhosted.org/packages/b2/ab/070ccfabe870d9f105b04aee1e2860520460ef7ca0213172abfe871463b9/scipy-1.14.1-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:2da0469a4ef0ecd3693761acbdc20f2fdeafb69e6819cc081308cc978153c675", size = 39076999, upload-time = "2024-08-21T00:04:32.61Z" }, + { url = "https://files.pythonhosted.org/packages/a7/c5/02ac82f9bb8f70818099df7e86c3ad28dae64e1347b421d8e3adf26acab6/scipy-1.14.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:c0ee987efa6737242745f347835da2cc5bb9f1b42996a4d97d5c7ff7928cb6f2", size = 29894570, upload-time = "2024-08-21T00:04:37.938Z" }, + { url = "https://files.pythonhosted.org/packages/ed/05/7f03e680cc5249c4f96c9e4e845acde08eb1aee5bc216eff8a089baa4ddb/scipy-1.14.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:3a1b111fac6baec1c1d92f27e76511c9e7218f1695d61b59e05e0fe04dc59617", size = 23103567, upload-time = "2024-08-21T00:04:42.582Z" }, + { url = "https://files.pythonhosted.org/packages/5e/fc/9f1413bef53171f379d786aabc104d4abeea48ee84c553a3e3d8c9f96a9c/scipy-1.14.1-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:8475230e55549ab3f207bff11ebfc91c805dc3463ef62eda3ccf593254524ce8", size = 25499102, upload-time = "2024-08-21T00:04:47.467Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4b/b44bee3c2ddc316b0159b3d87a3d467ef8d7edfd525e6f7364a62cd87d90/scipy-1.14.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:278266012eb69f4a720827bdd2dc54b2271c97d84255b2faaa8f161a158c3b37", size = 35586346, upload-time = "2024-08-21T00:04:53.872Z" }, + { url = "https://files.pythonhosted.org/packages/93/6b/701776d4bd6bdd9b629c387b5140f006185bd8ddea16788a44434376b98f/scipy-1.14.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fef8c87f8abfb884dac04e97824b61299880c43f4ce675dd2cbeadd3c9b466d2", size = 41165244, upload-time = "2024-08-21T00:05:00.489Z" }, + { url = "https://files.pythonhosted.org/packages/06/57/e6aa6f55729a8f245d8a6984f2855696c5992113a5dc789065020f8be753/scipy-1.14.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b05d43735bb2f07d689f56f7b474788a13ed8adc484a85aa65c0fd931cf9ccd2", size = 42817917, upload-time = "2024-08-21T00:05:07.533Z" }, + { url = "https://files.pythonhosted.org/packages/ea/c2/5ecadc5fcccefaece775feadcd795060adf5c3b29a883bff0e678cfe89af/scipy-1.14.1-cp311-cp311-win_amd64.whl", hash = "sha256:716e389b694c4bb564b4fc0c51bc84d381735e0d39d3f26ec1af2556ec6aad94", size = 44781033, upload-time = "2024-08-21T00:05:14.297Z" }, + { url = "https://files.pythonhosted.org/packages/c0/04/2bdacc8ac6387b15db6faa40295f8bd25eccf33f1f13e68a72dc3c60a99e/scipy-1.14.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:631f07b3734d34aced009aaf6fedfd0eb3498a97e581c3b1e5f14a04164a456d", size = 39128781, upload-time = "2024-08-21T04:08:04.15Z" }, + { url = "https://files.pythonhosted.org/packages/c8/53/35b4d41f5fd42f5781dbd0dd6c05d35ba8aa75c84ecddc7d44756cd8da2e/scipy-1.14.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:af29a935803cc707ab2ed7791c44288a682f9c8107bc00f0eccc4f92c08d6e07", size = 29939542, upload-time = "2024-08-21T00:05:25.758Z" }, + { url = "https://files.pythonhosted.org/packages/66/67/6ef192e0e4d77b20cc33a01e743b00bc9e68fb83b88e06e636d2619a8767/scipy-1.14.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:2843f2d527d9eebec9a43e6b406fb7266f3af25a751aa91d62ff416f54170bc5", size = 23148375, upload-time = "2024-08-21T00:05:30.359Z" }, + { url = "https://files.pythonhosted.org/packages/f6/32/3a6dedd51d68eb7b8e7dc7947d5d841bcb699f1bf4463639554986f4d782/scipy-1.14.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:eb58ca0abd96911932f688528977858681a59d61a7ce908ffd355957f7025cfc", size = 25578573, upload-time = "2024-08-21T00:05:35.274Z" }, + { url = "https://files.pythonhosted.org/packages/f0/5a/efa92a58dc3a2898705f1dc9dbaf390ca7d4fba26d6ab8cfffb0c72f656f/scipy-1.14.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:30ac8812c1d2aab7131a79ba62933a2a76f582d5dbbc695192453dae67ad6310", size = 35319299, upload-time = "2024-08-21T00:05:40.956Z" }, + { url = "https://files.pythonhosted.org/packages/8e/ee/8a26858ca517e9c64f84b4c7734b89bda8e63bec85c3d2f432d225bb1886/scipy-1.14.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f9ea80f2e65bdaa0b7627fb00cbeb2daf163caa015e59b7516395fe3bd1e066", size = 40849331, upload-time = "2024-08-21T00:05:47.53Z" }, + { url = "https://files.pythonhosted.org/packages/a5/cd/06f72bc9187840f1c99e1a8750aad4216fc7dfdd7df46e6280add14b4822/scipy-1.14.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:edaf02b82cd7639db00dbff629995ef185c8df4c3ffa71a5562a595765a06ce1", size = 42544049, upload-time = "2024-08-21T00:05:59.294Z" }, + { url = "https://files.pythonhosted.org/packages/aa/7d/43ab67228ef98c6b5dd42ab386eae2d7877036970a0d7e3dd3eb47a0d530/scipy-1.14.1-cp312-cp312-win_amd64.whl", hash = "sha256:2ff38e22128e6c03ff73b6bb0f85f897d2362f8c052e3b8ad00532198fbdae3f", size = 44521212, upload-time = "2024-08-21T00:06:06.521Z" }, + { url = "https://files.pythonhosted.org/packages/50/ef/ac98346db016ff18a6ad7626a35808f37074d25796fd0234c2bb0ed1e054/scipy-1.14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1729560c906963fc8389f6aac023739ff3983e727b1a4d87696b7bf108316a79", size = 39091068, upload-time = "2024-08-21T00:06:13.671Z" }, + { url = "https://files.pythonhosted.org/packages/b9/cc/70948fe9f393b911b4251e96b55bbdeaa8cca41f37c26fd1df0232933b9e/scipy-1.14.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:4079b90df244709e675cdc8b93bfd8a395d59af40b72e339c2287c91860deb8e", size = 29875417, upload-time = "2024-08-21T00:06:21.482Z" }, + { url = "https://files.pythonhosted.org/packages/3b/2e/35f549b7d231c1c9f9639f9ef49b815d816bf54dd050da5da1c11517a218/scipy-1.14.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:e0cf28db0f24a38b2a0ca33a85a54852586e43cf6fd876365c86e0657cfe7d73", size = 23084508, upload-time = "2024-08-21T00:06:28.064Z" }, + { url = "https://files.pythonhosted.org/packages/3f/d6/b028e3f3e59fae61fb8c0f450db732c43dd1d836223a589a8be9f6377203/scipy-1.14.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:0c2f95de3b04e26f5f3ad5bb05e74ba7f68b837133a4492414b3afd79dfe540e", size = 25503364, upload-time = "2024-08-21T00:06:35.25Z" }, + { url = "https://files.pythonhosted.org/packages/a7/2f/6c142b352ac15967744d62b165537a965e95d557085db4beab2a11f7943b/scipy-1.14.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b99722ea48b7ea25e8e015e8341ae74624f72e5f21fc2abd45f3a93266de4c5d", size = 35292639, upload-time = "2024-08-21T00:06:44.542Z" }, + { url = "https://files.pythonhosted.org/packages/56/46/2449e6e51e0d7c3575f289f6acb7f828938eaab8874dbccfeb0cd2b71a27/scipy-1.14.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5149e3fd2d686e42144a093b206aef01932a0059c2a33ddfa67f5f035bdfe13e", size = 40798288, upload-time = "2024-08-21T00:06:54.182Z" }, + { url = "https://files.pythonhosted.org/packages/32/cd/9d86f7ed7f4497c9fd3e39f8918dd93d9f647ba80d7e34e4946c0c2d1a7c/scipy-1.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e4f5a7c49323533f9103d4dacf4e4f07078f360743dec7f7596949149efeec06", size = 42524647, upload-time = "2024-08-21T00:07:04.649Z" }, + { url = "https://files.pythonhosted.org/packages/f5/1b/6ee032251bf4cdb0cc50059374e86a9f076308c1512b61c4e003e241efb7/scipy-1.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:baff393942b550823bfce952bb62270ee17504d02a1801d7fd0719534dfb9c84", size = 44469524, upload-time = "2024-08-21T00:07:15.381Z" }, +] + +[[package]] +name = "semantic-version" +version = "2.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/31/f2289ce78b9b473d582568c234e104d2a342fd658cc288a7553d83bb8595/semantic_version-2.10.0.tar.gz", hash = "sha256:bdabb6d336998cbb378d4b9db3a4b56a1e3235701dc05ea2690d9a997ed5041c", size = 52289, upload-time = "2022-05-26T13:35:23.454Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/23/8146aad7d88f4fcb3a6218f41a60f6c2d4e3a72de72da1825dc7c8f7877c/semantic_version-2.10.0-py2.py3-none-any.whl", hash = "sha256:de78a3b8e0feda74cabc54aab2da702113e33ac9d9eb9d2389bcf1f58b7d9177", size = 15552, upload-time = "2022-05-26T13:35:21.206Z" }, +] + +[[package]] +name = "setuptools" +version = "75.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ac/57/e6f0bde5a2c333a32fbcce201f906c1fd0b3a7144138712a5e9d9598c5ec/setuptools-75.7.0.tar.gz", hash = "sha256:886ff7b16cd342f1d1defc16fc98c9ce3fde69e087a4e1983d7ab634e5f41f4f", size = 1338616, upload-time = "2025-01-05T16:31:12.951Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/6e/abdfaaf5c294c553e7a81cf5d801fbb4f53f5c5b6646de651f92a2667547/setuptools-75.7.0-py3-none-any.whl", hash = "sha256:84fb203f278ebcf5cd08f97d3fb96d3fbed4b629d500b29ad60d11e00769b183", size = 1224467, upload-time = "2025-01-05T16:31:09.484Z" }, +] + +[[package]] +name = "shapely" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ca/3c/2da625233f4e605155926566c0e7ea8dda361877f48e8b1655e53456f252/shapely-2.1.1.tar.gz", hash = "sha256:500621967f2ffe9642454808009044c21e5b35db89ce69f8a2042c2ffd0e2772", size = 315422, upload-time = "2025-05-19T11:04:41.265Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/fa/f18025c95b86116dd8f1ec58cab078bd59ab51456b448136ca27463be533/shapely-2.1.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d8ccc872a632acb7bdcb69e5e78df27213f7efd195882668ffba5405497337c6", size = 1825117, upload-time = "2025-05-19T11:03:43.547Z" }, + { url = "https://files.pythonhosted.org/packages/c7/65/46b519555ee9fb851234288be7c78be11e6260995281071d13abf2c313d0/shapely-2.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f24f2ecda1e6c091da64bcbef8dd121380948074875bd1b247b3d17e99407099", size = 1628541, upload-time = "2025-05-19T11:03:45.162Z" }, + { url = "https://files.pythonhosted.org/packages/29/51/0b158a261df94e33505eadfe737db9531f346dfa60850945ad25fd4162f1/shapely-2.1.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45112a5be0b745b49e50f8829ce490eb67fefb0cea8d4f8ac5764bfedaa83d2d", size = 2948453, upload-time = "2025-05-19T11:03:46.681Z" }, + { url = "https://files.pythonhosted.org/packages/a9/4f/6c9bb4bd7b1a14d7051641b9b479ad2a643d5cbc382bcf5bd52fd0896974/shapely-2.1.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c10ce6f11904d65e9bbb3e41e774903c944e20b3f0b282559885302f52f224a", size = 3057029, upload-time = "2025-05-19T11:03:48.346Z" }, + { url = "https://files.pythonhosted.org/packages/89/0b/ad1b0af491d753a83ea93138eee12a4597f763ae12727968d05934fe7c78/shapely-2.1.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:61168010dfe4e45f956ffbbaf080c88afce199ea81eb1f0ac43230065df320bd", size = 3894342, upload-time = "2025-05-19T11:03:49.602Z" }, + { url = "https://files.pythonhosted.org/packages/7d/96/73232c5de0b9fdf0ec7ddfc95c43aaf928740e87d9f168bff0e928d78c6d/shapely-2.1.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cacf067cdff741cd5c56a21c52f54ece4e4dad9d311130493a791997da4a886b", size = 4056766, upload-time = "2025-05-19T11:03:51.252Z" }, + { url = "https://files.pythonhosted.org/packages/43/cc/eec3c01f754f5b3e0c47574b198f9deb70465579ad0dad0e1cef2ce9e103/shapely-2.1.1-cp310-cp310-win32.whl", hash = "sha256:23b8772c3b815e7790fb2eab75a0b3951f435bc0fce7bb146cb064f17d35ab4f", size = 1523744, upload-time = "2025-05-19T11:03:52.624Z" }, + { url = "https://files.pythonhosted.org/packages/50/fc/a7187e6dadb10b91e66a9e715d28105cde6489e1017cce476876185a43da/shapely-2.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:2c7b2b6143abf4fa77851cef8ef690e03feade9a0d48acd6dc41d9e0e78d7ca6", size = 1703061, upload-time = "2025-05-19T11:03:54.695Z" }, + { url = "https://files.pythonhosted.org/packages/19/97/2df985b1e03f90c503796ad5ecd3d9ed305123b64d4ccb54616b30295b29/shapely-2.1.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:587a1aa72bc858fab9b8c20427b5f6027b7cbc92743b8e2c73b9de55aa71c7a7", size = 1819368, upload-time = "2025-05-19T11:03:55.937Z" }, + { url = "https://files.pythonhosted.org/packages/56/17/504518860370f0a28908b18864f43d72f03581e2b6680540ca668f07aa42/shapely-2.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9fa5c53b0791a4b998f9ad84aad456c988600757a96b0a05e14bba10cebaaaea", size = 1625362, upload-time = "2025-05-19T11:03:57.06Z" }, + { url = "https://files.pythonhosted.org/packages/36/a1/9677337d729b79fce1ef3296aac6b8ef4743419086f669e8a8070eff8f40/shapely-2.1.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aabecd038841ab5310d23495253f01c2a82a3aedae5ab9ca489be214aa458aa7", size = 2999005, upload-time = "2025-05-19T11:03:58.692Z" }, + { url = "https://files.pythonhosted.org/packages/a2/17/e09357274699c6e012bbb5a8ea14765a4d5860bb658df1931c9f90d53bd3/shapely-2.1.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:586f6aee1edec04e16227517a866df3e9a2e43c1f635efc32978bb3dc9c63753", size = 3108489, upload-time = "2025-05-19T11:04:00.059Z" }, + { url = "https://files.pythonhosted.org/packages/17/5d/93a6c37c4b4e9955ad40834f42b17260ca74ecf36df2e81bb14d12221b90/shapely-2.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b9878b9e37ad26c72aada8de0c9cfe418d9e2ff36992a1693b7f65a075b28647", size = 3945727, upload-time = "2025-05-19T11:04:01.786Z" }, + { url = "https://files.pythonhosted.org/packages/a3/1a/ad696648f16fd82dd6bfcca0b3b8fbafa7aacc13431c7fc4c9b49e481681/shapely-2.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d9a531c48f289ba355e37b134e98e28c557ff13965d4653a5228d0f42a09aed0", size = 4109311, upload-time = "2025-05-19T11:04:03.134Z" }, + { url = "https://files.pythonhosted.org/packages/d4/38/150dd245beab179ec0d4472bf6799bf18f21b1efbef59ac87de3377dbf1c/shapely-2.1.1-cp311-cp311-win32.whl", hash = "sha256:4866de2673a971820c75c0167b1f1cd8fb76f2d641101c23d3ca021ad0449bab", size = 1522982, upload-time = "2025-05-19T11:04:05.217Z" }, + { url = "https://files.pythonhosted.org/packages/93/5b/842022c00fbb051083c1c85430f3bb55565b7fd2d775f4f398c0ba8052ce/shapely-2.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:20a9d79958b3d6c70d8a886b250047ea32ff40489d7abb47d01498c704557a93", size = 1703872, upload-time = "2025-05-19T11:04:06.791Z" }, + { url = "https://files.pythonhosted.org/packages/fb/64/9544dc07dfe80a2d489060791300827c941c451e2910f7364b19607ea352/shapely-2.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2827365b58bf98efb60affc94a8e01c56dd1995a80aabe4b701465d86dcbba43", size = 1833021, upload-time = "2025-05-19T11:04:08.022Z" }, + { url = "https://files.pythonhosted.org/packages/07/aa/fb5f545e72e89b6a0f04a0effda144f5be956c9c312c7d4e00dfddbddbcf/shapely-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a9c551f7fa7f1e917af2347fe983f21f212863f1d04f08eece01e9c275903fad", size = 1643018, upload-time = "2025-05-19T11:04:09.343Z" }, + { url = "https://files.pythonhosted.org/packages/03/46/61e03edba81de729f09d880ce7ae5c1af873a0814206bbfb4402ab5c3388/shapely-2.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:78dec4d4fbe7b1db8dc36de3031767e7ece5911fb7782bc9e95c5cdec58fb1e9", size = 2986417, upload-time = "2025-05-19T11:04:10.56Z" }, + { url = "https://files.pythonhosted.org/packages/1f/1e/83ec268ab8254a446b4178b45616ab5822d7b9d2b7eb6e27cf0b82f45601/shapely-2.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:872d3c0a7b8b37da0e23d80496ec5973c4692920b90de9f502b5beb994bbaaef", size = 3098224, upload-time = "2025-05-19T11:04:11.903Z" }, + { url = "https://files.pythonhosted.org/packages/f1/44/0c21e7717c243e067c9ef8fa9126de24239f8345a5bba9280f7bb9935959/shapely-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2e2b9125ebfbc28ecf5353511de62f75a8515ae9470521c9a693e4bb9fbe0cf1", size = 3925982, upload-time = "2025-05-19T11:04:13.224Z" }, + { url = "https://files.pythonhosted.org/packages/15/50/d3b4e15fefc103a0eb13d83bad5f65cd6e07a5d8b2ae920e767932a247d1/shapely-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4b96cea171b3d7f6786976a0520f178c42792897653ecca0c5422fb1e6946e6d", size = 4089122, upload-time = "2025-05-19T11:04:14.477Z" }, + { url = "https://files.pythonhosted.org/packages/bd/05/9a68f27fc6110baeedeeebc14fd86e73fa38738c5b741302408fb6355577/shapely-2.1.1-cp312-cp312-win32.whl", hash = "sha256:39dca52201e02996df02e447f729da97cfb6ff41a03cb50f5547f19d02905af8", size = 1522437, upload-time = "2025-05-19T11:04:16.203Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e9/a4560e12b9338842a1f82c9016d2543eaa084fce30a1ca11991143086b57/shapely-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:13d643256f81d55a50013eff6321142781cf777eb6a9e207c2c9e6315ba6044a", size = 1703479, upload-time = "2025-05-19T11:04:18.497Z" }, + { url = "https://files.pythonhosted.org/packages/71/8e/2bc836437f4b84d62efc1faddce0d4e023a5d990bbddd3c78b2004ebc246/shapely-2.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3004a644d9e89e26c20286d5fdc10f41b1744c48ce910bd1867fdff963fe6c48", size = 1832107, upload-time = "2025-05-19T11:04:19.736Z" }, + { url = "https://files.pythonhosted.org/packages/12/a2/12c7cae5b62d5d851c2db836eadd0986f63918a91976495861f7c492f4a9/shapely-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1415146fa12d80a47d13cfad5310b3c8b9c2aa8c14a0c845c9d3d75e77cb54f6", size = 1642355, upload-time = "2025-05-19T11:04:21.035Z" }, + { url = "https://files.pythonhosted.org/packages/5b/7e/6d28b43d53fea56de69c744e34c2b999ed4042f7a811dc1bceb876071c95/shapely-2.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:21fcab88b7520820ec16d09d6bea68652ca13993c84dffc6129dc3607c95594c", size = 2968871, upload-time = "2025-05-19T11:04:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/dd/87/1017c31e52370b2b79e4d29e07cbb590ab9e5e58cf7e2bdfe363765d6251/shapely-2.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e5ce6a5cc52c974b291237a96c08c5592e50f066871704fb5b12be2639d9026a", size = 3080830, upload-time = "2025-05-19T11:04:23.997Z" }, + { url = "https://files.pythonhosted.org/packages/1d/fe/f4a03d81abd96a6ce31c49cd8aaba970eaaa98e191bd1e4d43041e57ae5a/shapely-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:04e4c12a45a1d70aeb266618d8cf81a2de9c4df511b63e105b90bfdfb52146de", size = 3908961, upload-time = "2025-05-19T11:04:25.702Z" }, + { url = "https://files.pythonhosted.org/packages/ef/59/7605289a95a6844056a2017ab36d9b0cb9d6a3c3b5317c1f968c193031c9/shapely-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6ca74d851ca5264aae16c2b47e96735579686cb69fa93c4078070a0ec845b8d8", size = 4079623, upload-time = "2025-05-19T11:04:27.171Z" }, + { url = "https://files.pythonhosted.org/packages/bc/4d/9fea036eff2ef4059d30247128b2d67aaa5f0b25e9fc27e1d15cc1b84704/shapely-2.1.1-cp313-cp313-win32.whl", hash = "sha256:fd9130501bf42ffb7e0695b9ea17a27ae8ce68d50b56b6941c7f9b3d3453bc52", size = 1521916, upload-time = "2025-05-19T11:04:28.405Z" }, + { url = "https://files.pythonhosted.org/packages/12/d9/6d13b8957a17c95794f0c4dfb65ecd0957e6c7131a56ce18d135c1107a52/shapely-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:ab8d878687b438a2f4c138ed1a80941c6ab0029e0f4c785ecfe114413b498a97", size = 1702746, upload-time = "2025-05-19T11:04:29.643Z" }, + { url = "https://files.pythonhosted.org/packages/60/36/b1452e3e7f35f5f6454d96f3be6e2bb87082720ff6c9437ecc215fa79be0/shapely-2.1.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0c062384316a47f776305ed2fa22182717508ffdeb4a56d0ff4087a77b2a0f6d", size = 1833482, upload-time = "2025-05-19T11:04:30.852Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ca/8e6f59be0718893eb3e478141285796a923636dc8f086f83e5b0ec0036d0/shapely-2.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4ecf6c196b896e8f1360cc219ed4eee1c1e5f5883e505d449f263bd053fb8c05", size = 1642256, upload-time = "2025-05-19T11:04:32.068Z" }, + { url = "https://files.pythonhosted.org/packages/ab/78/0053aea449bb1d4503999525fec6232f049abcdc8df60d290416110de943/shapely-2.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb00070b4c4860f6743c600285109c273cca5241e970ad56bb87bef0be1ea3a0", size = 3016614, upload-time = "2025-05-19T11:04:33.7Z" }, + { url = "https://files.pythonhosted.org/packages/ee/53/36f1b1de1dfafd1b457dcbafa785b298ce1b8a3e7026b79619e708a245d5/shapely-2.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d14a9afa5fa980fbe7bf63706fdfb8ff588f638f145a1d9dbc18374b5b7de913", size = 3093542, upload-time = "2025-05-19T11:04:34.952Z" }, + { url = "https://files.pythonhosted.org/packages/b9/bf/0619f37ceec6b924d84427c88835b61f27f43560239936ff88915c37da19/shapely-2.1.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b640e390dabde790e3fb947198b466e63223e0a9ccd787da5f07bcb14756c28d", size = 3945961, upload-time = "2025-05-19T11:04:36.32Z" }, + { url = "https://files.pythonhosted.org/packages/93/c9/20ca4afeb572763b07a7997f00854cb9499df6af85929e93012b189d8917/shapely-2.1.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:69e08bf9697c1b73ec6aa70437db922bafcea7baca131c90c26d59491a9760f9", size = 4089514, upload-time = "2025-05-19T11:04:37.683Z" }, + { url = "https://files.pythonhosted.org/packages/33/6a/27036a5a560b80012a544366bceafd491e8abb94a8db14047b5346b5a749/shapely-2.1.1-cp313-cp313t-win32.whl", hash = "sha256:ef2d09d5a964cc90c2c18b03566cf918a61c248596998a0301d5b632beadb9db", size = 1540607, upload-time = "2025-05-19T11:04:38.925Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f1/5e9b3ba5c7aa7ebfaf269657e728067d16a7c99401c7973ddf5f0cf121bd/shapely-2.1.1-cp313-cp313t-win_amd64.whl", hash = "sha256:8cb8f17c377260452e9d7720eeaf59082c5f8ea48cf104524d953e5d36d4bdb7", size = 1723061, upload-time = "2025-05-19T11:04:40.082Z" }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + +[[package]] +name = "stack-data" +version = "0.6.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asttokens" }, + { name = "executing" }, + { name = "pure-eval" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/28/e3/55dcc2cfbc3ca9c29519eb6884dd1415ecb53b0e934862d3559ddcb7e20b/stack_data-0.6.3.tar.gz", hash = "sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9", size = 44707, upload-time = "2023-09-30T13:58:05.479Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695", size = 24521, upload-time = "2023-09-30T13:58:03.53Z" }, +] + +[[package]] +name = "starlette" +version = "0.46.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ce/20/08dfcd9c983f6a6f4a1000d934b9e6d626cff8d2eeb77a89a68eef20a2b7/starlette-0.46.2.tar.gz", hash = "sha256:7f7361f34eed179294600af672f565727419830b54b7b084efe44bb82d2fccd5", size = 2580846, upload-time = "2025-04-13T13:56:17.942Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/0c/9d30a4ebeb6db2b25a841afbb80f6ef9a854fc3b41be131d249a977b4959/starlette-0.46.2-py3-none-any.whl", hash = "sha256:595633ce89f8ffa71a015caed34a5b2dc1c0cdb3f0f1fbd1e69339cf2abeec35", size = 72037, upload-time = "2025-04-13T13:56:16.21Z" }, +] + +[[package]] +name = "supervision" +version = "0.26.0rc7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "defusedxml" }, + { name = "matplotlib" }, + { name = "numpy" }, + { name = "opencv-python" }, + { name = "pillow" }, + { name = "pyyaml" }, + { name = "requests" }, + { name = "scipy" }, + { name = "tqdm" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/16/a8/1d9b70f41985c65544a15483302720ca22f7cbaf163aacab8ba647832f29/supervision-0.26.0rc7.tar.gz", hash = "sha256:428f01ada109c119a1c05dd9c72eec603d0e4b51e5e0285a34d40db68769ff3d", size = 154961, upload-time = "2025-04-25T12:57:45.808Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/e1/a9de01b0c424a2140de476b9e94e06112a239111772930f491cef178195c/supervision-0.26.0rc7-py3-none-any.whl", hash = "sha256:f125dc69335ccaa7bfc761d2847d131f00bcefe9238e40303ee4ec0df7259f35", size = 187228, upload-time = "2025-04-25T12:57:43.66Z" }, +] + +[[package]] +name = "sympy" +version = "1.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mpmath" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/83/d3/803453b36afefb7c2bb238361cd4ae6125a569b4db67cd9e79846ba2d68c/sympy-1.14.0.tar.gz", hash = "sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517", size = 7793921, upload-time = "2025-04-27T18:05:01.611Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5", size = 6299353, upload-time = "2025-04-27T18:04:59.103Z" }, +] + +[[package]] +name = "tabulate" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/fe/802052aecb21e3797b8f7902564ab6ea0d60ff8ca23952079064155d1ae1/tabulate-0.9.0.tar.gz", hash = "sha256:0095b12bf5966de529c0feb1fa08671671b3368eec77d7ef7ab114be2c068b3c", size = 81090, upload-time = "2022-10-06T17:21:48.54Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/44/4a5f08c96eb108af5cb50b41f76142f0afa346dfa99d5296fe7202a11854/tabulate-0.9.0-py3-none-any.whl", hash = "sha256:024ca478df22e9340661486f85298cff5f6dcdba14f3813e8830015b9ed1948f", size = 35252, upload-time = "2022-10-06T17:21:44.262Z" }, +] + +[[package]] +name = "tensorboard" +version = "2.19.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "absl-py" }, + { name = "grpcio" }, + { name = "markdown" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "protobuf" }, + { name = "setuptools" }, + { name = "six" }, + { name = "tensorboard-data-server" }, + { name = "werkzeug" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/12/4f70e8e2ba0dbe72ea978429d8530b0333f0ed2140cc571a48802878ef99/tensorboard-2.19.0-py3-none-any.whl", hash = "sha256:5e71b98663a641a7ce8a6e70b0be8e1a4c0c45d48760b076383ac4755c35b9a0", size = 5503412, upload-time = "2025-02-12T08:17:27.21Z" }, +] + +[[package]] +name = "tensorboard-data-server" +version = "0.7.2" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/13/e503968fefabd4c6b2650af21e110aa8466fe21432cd7c43a84577a89438/tensorboard_data_server-0.7.2-py3-none-any.whl", hash = "sha256:7e0610d205889588983836ec05dc098e80f97b7e7bbff7e994ebb78f578d0ddb", size = 2356, upload-time = "2023-10-23T21:23:32.16Z" }, + { url = "https://files.pythonhosted.org/packages/b7/85/dabeaf902892922777492e1d253bb7e1264cadce3cea932f7ff599e53fea/tensorboard_data_server-0.7.2-py3-none-macosx_10_9_x86_64.whl", hash = "sha256:9fe5d24221b29625dbc7328b0436ca7fc1c23de4acf4d272f1180856e32f9f60", size = 4823598, upload-time = "2023-10-23T21:23:33.714Z" }, + { url = "https://files.pythonhosted.org/packages/73/c6/825dab04195756cf8ff2e12698f22513b3db2f64925bdd41671bfb33aaa5/tensorboard_data_server-0.7.2-py3-none-manylinux_2_31_x86_64.whl", hash = "sha256:ef687163c24185ae9754ed5650eb5bc4d84ff257aabdc33f0cc6f74d8ba54530", size = 6590363, upload-time = "2023-10-23T21:23:35.583Z" }, +] + +[[package]] +name = "tensorrt" +version = "10.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tensorrt-cu12" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ee/b9/f917eb7dfe02da30bc91206a464c850f4b94a1e14b8f95870074c9b9abea/tensorrt-10.5.0.tar.gz", hash = "sha256:d5c6338d44aeda20250fdbe31f9df8ca152b830f811aaf19d6c4d1dafd18c84b", size = 16401, upload-time = "2024-09-30T21:24:25.512Z" } + +[[package]] +name = "tensorrt-cu12" +version = "10.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/d5/a4c3e22482d4273e151123990934d7c8d0ba1e4efb9a483eba807cdce279/tensorrt-cu12-10.5.0.tar.gz", hash = "sha256:46edbda08c54c8ffa88c75d75b4761eb9839e81678135e8d1530adc8cef6a61b", size = 18341, upload-time = "2024-09-30T21:24:43.864Z" } + +[[package]] +name = "termcolor" +version = "3.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ca/6c/3d75c196ac07ac8749600b60b03f4f6094d54e132c4d94ebac6ee0e0add0/termcolor-3.1.0.tar.gz", hash = "sha256:6a6dd7fbee581909eeec6a756cff1d7f7c376063b14e4a298dc4980309e55970", size = 14324, upload-time = "2025-04-30T11:37:53.791Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4f/bd/de8d508070629b6d84a30d01d57e4a65c69aa7f5abe7560b8fad3b50ea59/termcolor-3.1.0-py3-none-any.whl", hash = "sha256:591dd26b5c2ce03b9e43f391264626557873ce1d379019786f99b0c2bee140aa", size = 7684, upload-time = "2025-04-30T11:37:52.382Z" }, +] + +[[package]] +name = "tomli" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/87/302344fed471e44a87289cf4967697d07e532f2421fdaf868a303cbae4ff/tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff", size = 17175, upload-time = "2024-11-27T22:38:36.873Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/ca/75707e6efa2b37c77dadb324ae7d9571cb424e61ea73fad7c56c2d14527f/tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249", size = 131077, upload-time = "2024-11-27T22:37:54.956Z" }, + { url = "https://files.pythonhosted.org/packages/c7/16/51ae563a8615d472fdbffc43a3f3d46588c264ac4f024f63f01283becfbb/tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6", size = 123429, upload-time = "2024-11-27T22:37:56.698Z" }, + { url = "https://files.pythonhosted.org/packages/f1/dd/4f6cd1e7b160041db83c694abc78e100473c15d54620083dbd5aae7b990e/tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a", size = 226067, upload-time = "2024-11-27T22:37:57.63Z" }, + { url = "https://files.pythonhosted.org/packages/a9/6b/c54ede5dc70d648cc6361eaf429304b02f2871a345bbdd51e993d6cdf550/tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee", size = 236030, upload-time = "2024-11-27T22:37:59.344Z" }, + { url = "https://files.pythonhosted.org/packages/1f/47/999514fa49cfaf7a92c805a86c3c43f4215621855d151b61c602abb38091/tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e", size = 240898, upload-time = "2024-11-27T22:38:00.429Z" }, + { url = "https://files.pythonhosted.org/packages/73/41/0a01279a7ae09ee1573b423318e7934674ce06eb33f50936655071d81a24/tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4", size = 229894, upload-time = "2024-11-27T22:38:02.094Z" }, + { url = "https://files.pythonhosted.org/packages/55/18/5d8bc5b0a0362311ce4d18830a5d28943667599a60d20118074ea1b01bb7/tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106", size = 245319, upload-time = "2024-11-27T22:38:03.206Z" }, + { url = "https://files.pythonhosted.org/packages/92/a3/7ade0576d17f3cdf5ff44d61390d4b3febb8a9fc2b480c75c47ea048c646/tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8", size = 238273, upload-time = "2024-11-27T22:38:04.217Z" }, + { url = "https://files.pythonhosted.org/packages/72/6f/fa64ef058ac1446a1e51110c375339b3ec6be245af9d14c87c4a6412dd32/tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff", size = 98310, upload-time = "2024-11-27T22:38:05.908Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1c/4a2dcde4a51b81be3530565e92eda625d94dafb46dbeb15069df4caffc34/tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b", size = 108309, upload-time = "2024-11-27T22:38:06.812Z" }, + { url = "https://files.pythonhosted.org/packages/52/e1/f8af4c2fcde17500422858155aeb0d7e93477a0d59a98e56cbfe75070fd0/tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea", size = 132762, upload-time = "2024-11-27T22:38:07.731Z" }, + { url = "https://files.pythonhosted.org/packages/03/b8/152c68bb84fc00396b83e7bbddd5ec0bd3dd409db4195e2a9b3e398ad2e3/tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8", size = 123453, upload-time = "2024-11-27T22:38:09.384Z" }, + { url = "https://files.pythonhosted.org/packages/c8/d6/fc9267af9166f79ac528ff7e8c55c8181ded34eb4b0e93daa767b8841573/tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192", size = 233486, upload-time = "2024-11-27T22:38:10.329Z" }, + { url = "https://files.pythonhosted.org/packages/5c/51/51c3f2884d7bab89af25f678447ea7d297b53b5a3b5730a7cb2ef6069f07/tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222", size = 242349, upload-time = "2024-11-27T22:38:11.443Z" }, + { url = "https://files.pythonhosted.org/packages/ab/df/bfa89627d13a5cc22402e441e8a931ef2108403db390ff3345c05253935e/tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77", size = 252159, upload-time = "2024-11-27T22:38:13.099Z" }, + { url = "https://files.pythonhosted.org/packages/9e/6e/fa2b916dced65763a5168c6ccb91066f7639bdc88b48adda990db10c8c0b/tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6", size = 237243, upload-time = "2024-11-27T22:38:14.766Z" }, + { url = "https://files.pythonhosted.org/packages/b4/04/885d3b1f650e1153cbb93a6a9782c58a972b94ea4483ae4ac5cedd5e4a09/tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd", size = 259645, upload-time = "2024-11-27T22:38:15.843Z" }, + { url = "https://files.pythonhosted.org/packages/9c/de/6b432d66e986e501586da298e28ebeefd3edc2c780f3ad73d22566034239/tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e", size = 244584, upload-time = "2024-11-27T22:38:17.645Z" }, + { url = "https://files.pythonhosted.org/packages/1c/9a/47c0449b98e6e7d1be6cbac02f93dd79003234ddc4aaab6ba07a9a7482e2/tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98", size = 98875, upload-time = "2024-11-27T22:38:19.159Z" }, + { url = "https://files.pythonhosted.org/packages/ef/60/9b9638f081c6f1261e2688bd487625cd1e660d0a85bd469e91d8db969734/tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4", size = 109418, upload-time = "2024-11-27T22:38:20.064Z" }, + { url = "https://files.pythonhosted.org/packages/04/90/2ee5f2e0362cb8a0b6499dc44f4d7d48f8fff06d28ba46e6f1eaa61a1388/tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7", size = 132708, upload-time = "2024-11-27T22:38:21.659Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ec/46b4108816de6b385141f082ba99e315501ccd0a2ea23db4a100dd3990ea/tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c", size = 123582, upload-time = "2024-11-27T22:38:22.693Z" }, + { url = "https://files.pythonhosted.org/packages/a0/bd/b470466d0137b37b68d24556c38a0cc819e8febe392d5b199dcd7f578365/tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13", size = 232543, upload-time = "2024-11-27T22:38:24.367Z" }, + { url = "https://files.pythonhosted.org/packages/d9/e5/82e80ff3b751373f7cead2815bcbe2d51c895b3c990686741a8e56ec42ab/tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281", size = 241691, upload-time = "2024-11-27T22:38:26.081Z" }, + { url = "https://files.pythonhosted.org/packages/05/7e/2a110bc2713557d6a1bfb06af23dd01e7dde52b6ee7dadc589868f9abfac/tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272", size = 251170, upload-time = "2024-11-27T22:38:27.921Z" }, + { url = "https://files.pythonhosted.org/packages/64/7b/22d713946efe00e0adbcdfd6d1aa119ae03fd0b60ebed51ebb3fa9f5a2e5/tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140", size = 236530, upload-time = "2024-11-27T22:38:29.591Z" }, + { url = "https://files.pythonhosted.org/packages/38/31/3a76f67da4b0cf37b742ca76beaf819dca0ebef26d78fc794a576e08accf/tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2", size = 258666, upload-time = "2024-11-27T22:38:30.639Z" }, + { url = "https://files.pythonhosted.org/packages/07/10/5af1293da642aded87e8a988753945d0cf7e00a9452d3911dd3bb354c9e2/tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744", size = 243954, upload-time = "2024-11-27T22:38:31.702Z" }, + { url = "https://files.pythonhosted.org/packages/5b/b9/1ed31d167be802da0fc95020d04cd27b7d7065cc6fbefdd2f9186f60d7bd/tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec", size = 98724, upload-time = "2024-11-27T22:38:32.837Z" }, + { url = "https://files.pythonhosted.org/packages/c7/32/b0963458706accd9afcfeb867c0f9175a741bf7b19cd424230714d722198/tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69", size = 109383, upload-time = "2024-11-27T22:38:34.455Z" }, + { url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257, upload-time = "2024-11-27T22:38:35.385Z" }, +] + +[[package]] +name = "tomlkit" +version = "0.13.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b1/09/a439bec5888f00a54b8b9f05fa94d7f901d6735ef4e55dcec9bc37b5d8fa/tomlkit-0.13.2.tar.gz", hash = "sha256:fff5fe59a87295b278abd31bec92c15d9bc4a06885ab12bcea52c71119392e79", size = 192885, upload-time = "2024-08-14T08:19:41.488Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/b6/a447b5e4ec71e13871be01ba81f5dfc9d0af7e473da256ff46bc0e24026f/tomlkit-0.13.2-py3-none-any.whl", hash = "sha256:7a974427f6e119197f670fbbbeae7bef749a6c14e793db934baefc1b5f03efde", size = 37955, upload-time = "2024-08-14T08:19:40.05Z" }, +] + +[[package]] +name = "torch" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock" }, + { name = "fsspec" }, + { name = "jinja2" }, + { name = "networkx" }, + { name = "nvidia-cublas-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-cuda-cupti-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-cuda-nvrtc-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-cuda-runtime-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-cudnn-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-cufft-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-cufile-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-curand-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-cusolver-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-cusparse-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-cusparselt-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-nccl-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-nvjitlink-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-nvtx-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "setuptools", marker = "python_full_version >= '3.12'" }, + { name = "sympy" }, + { name = "triton", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "typing-extensions" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/c2/3fb87940fa160d956ee94d644d37b99a24b9c05a4222bf34f94c71880e28/torch-2.7.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:c9afea41b11e1a1ab1b258a5c31afbd646d6319042bfe4f231b408034b51128b", size = 99158447, upload-time = "2025-04-23T14:35:10.557Z" }, + { url = "https://files.pythonhosted.org/packages/cc/2c/91d1de65573fce563f5284e69d9c56b57289625cffbbb6d533d5d56c36a5/torch-2.7.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:0b9960183b6e5b71239a3e6c883d8852c304e691c0b2955f7045e8a6d05b9183", size = 865164221, upload-time = "2025-04-23T14:33:27.864Z" }, + { url = "https://files.pythonhosted.org/packages/7f/7e/1b1cc4e0e7cc2666cceb3d250eef47a205f0821c330392cf45eb08156ce5/torch-2.7.0-cp310-cp310-win_amd64.whl", hash = "sha256:2ad79d0d8c2a20a37c5df6052ec67c2078a2c4e9a96dd3a8b55daaff6d28ea29", size = 212521189, upload-time = "2025-04-23T14:34:53.898Z" }, + { url = "https://files.pythonhosted.org/packages/dc/0b/b2b83f30b8e84a51bf4f96aa3f5f65fdf7c31c591cc519310942339977e2/torch-2.7.0-cp310-none-macosx_11_0_arm64.whl", hash = "sha256:34e0168ed6de99121612d72224e59b2a58a83dae64999990eada7260c5dd582d", size = 68559462, upload-time = "2025-04-23T14:35:39.889Z" }, + { url = "https://files.pythonhosted.org/packages/40/da/7378d16cc636697f2a94f791cb496939b60fb8580ddbbef22367db2c2274/torch-2.7.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:2b7813e904757b125faf1a9a3154e1d50381d539ced34da1992f52440567c156", size = 99159397, upload-time = "2025-04-23T14:35:35.304Z" }, + { url = "https://files.pythonhosted.org/packages/0e/6b/87fcddd34df9f53880fa1f0c23af7b6b96c935856473faf3914323588c40/torch-2.7.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:fd5cfbb4c3bbadd57ad1b27d56a28008f8d8753733411a140fcfb84d7f933a25", size = 865183681, upload-time = "2025-04-23T14:34:21.802Z" }, + { url = "https://files.pythonhosted.org/packages/13/85/6c1092d4b06c3db1ed23d4106488750917156af0b24ab0a2d9951830b0e9/torch-2.7.0-cp311-cp311-win_amd64.whl", hash = "sha256:58df8d5c2eeb81305760282b5069ea4442791a6bbf0c74d9069b7b3304ff8a37", size = 212520100, upload-time = "2025-04-23T14:35:27.473Z" }, + { url = "https://files.pythonhosted.org/packages/aa/3f/85b56f7e2abcfa558c5fbf7b11eb02d78a4a63e6aeee2bbae3bb552abea5/torch-2.7.0-cp311-none-macosx_11_0_arm64.whl", hash = "sha256:0a8d43caa342b9986101ec5feb5bbf1d86570b5caa01e9cb426378311258fdde", size = 68569377, upload-time = "2025-04-23T14:35:20.361Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5e/ac759f4c0ab7c01feffa777bd68b43d2ac61560a9770eeac074b450f81d4/torch-2.7.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:36a6368c7ace41ad1c0f69f18056020b6a5ca47bedaca9a2f3b578f5a104c26c", size = 99013250, upload-time = "2025-04-23T14:35:15.589Z" }, + { url = "https://files.pythonhosted.org/packages/9c/58/2d245b6f1ef61cf11dfc4aceeaacbb40fea706ccebac3f863890c720ab73/torch-2.7.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:15aab3e31c16feb12ae0a88dba3434a458874636f360c567caa6a91f6bfba481", size = 865042157, upload-time = "2025-04-23T14:32:56.011Z" }, + { url = "https://files.pythonhosted.org/packages/44/80/b353c024e6b624cd9ce1d66dcb9d24e0294680f95b369f19280e241a0159/torch-2.7.0-cp312-cp312-win_amd64.whl", hash = "sha256:f56d4b2510934e072bab3ab8987e00e60e1262fb238176168f5e0c43a1320c6d", size = 212482262, upload-time = "2025-04-23T14:35:03.527Z" }, + { url = "https://files.pythonhosted.org/packages/ee/8d/b2939e5254be932db1a34b2bd099070c509e8887e0c5a90c498a917e4032/torch-2.7.0-cp312-none-macosx_11_0_arm64.whl", hash = "sha256:30b7688a87239a7de83f269333651d8e582afffce6f591fff08c046f7787296e", size = 68574294, upload-time = "2025-04-23T14:34:47.098Z" }, + { url = "https://files.pythonhosted.org/packages/14/24/720ea9a66c29151b315ea6ba6f404650834af57a26b2a04af23ec246b2d5/torch-2.7.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:868ccdc11798535b5727509480cd1d86d74220cfdc42842c4617338c1109a205", size = 99015553, upload-time = "2025-04-23T14:34:41.075Z" }, + { url = "https://files.pythonhosted.org/packages/4b/27/285a8cf12bd7cd71f9f211a968516b07dcffed3ef0be585c6e823675ab91/torch-2.7.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:9b52347118116cf3dff2ab5a3c3dd97c719eb924ac658ca2a7335652076df708", size = 865046389, upload-time = "2025-04-23T14:32:01.16Z" }, + { url = "https://files.pythonhosted.org/packages/74/c8/2ab2b6eadc45554af8768ae99668c5a8a8552e2012c7238ded7e9e4395e1/torch-2.7.0-cp313-cp313-win_amd64.whl", hash = "sha256:434cf3b378340efc87c758f250e884f34460624c0523fe5c9b518d205c91dd1b", size = 212490304, upload-time = "2025-04-23T14:33:57.108Z" }, + { url = "https://files.pythonhosted.org/packages/28/fd/74ba6fde80e2b9eef4237fe668ffae302c76f0e4221759949a632ca13afa/torch-2.7.0-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:edad98dddd82220465b106506bb91ee5ce32bd075cddbcf2b443dfaa2cbd83bf", size = 68856166, upload-time = "2025-04-23T14:34:04.012Z" }, + { url = "https://files.pythonhosted.org/packages/cb/b4/8df3f9fe6bdf59e56a0e538592c308d18638eb5f5dc4b08d02abb173c9f0/torch-2.7.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:2a885fc25afefb6e6eb18a7d1e8bfa01cc153e92271d980a49243b250d5ab6d9", size = 99091348, upload-time = "2025-04-23T14:33:48.975Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f5/0bd30e9da04c3036614aa1b935a9f7e505a9e4f1f731b15e165faf8a4c74/torch-2.7.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:176300ff5bc11a5f5b0784e40bde9e10a35c4ae9609beed96b4aeb46a27f5fae", size = 865104023, upload-time = "2025-04-23T14:30:40.537Z" }, + { url = "https://files.pythonhosted.org/packages/d1/b7/2235d0c3012c596df1c8d39a3f4afc1ee1b6e318d469eda4c8bb68566448/torch-2.7.0-cp313-cp313t-win_amd64.whl", hash = "sha256:d0ca446a93f474985d81dc866fcc8dccefb9460a29a456f79d99c29a78a66993", size = 212750916, upload-time = "2025-04-23T14:32:22.91Z" }, + { url = "https://files.pythonhosted.org/packages/90/48/7e6477cf40d48cc0a61fa0d41ee9582b9a316b12772fcac17bc1a40178e7/torch-2.7.0-cp313-none-macosx_11_0_arm64.whl", hash = "sha256:27f5007bdf45f7bb7af7f11d1828d5c2487e030690afb3d89a651fd7036a390e", size = 68575074, upload-time = "2025-04-23T14:32:38.136Z" }, +] + +[[package]] +name = "torchvision" +version = "0.22.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "pillow" }, + { name = "torch" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/03/a514766f068b088180f273913e539d08e830be3ae46ef8577ea62584a27c/torchvision-0.22.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:72256f1d7ff510b16c9fb4dd488584d0693f40c792f286a9620674438a81ccca", size = 1947829, upload-time = "2025-04-23T14:42:04.652Z" }, + { url = "https://files.pythonhosted.org/packages/a3/e5/ec4b52041cd8c440521b75864376605756bd2d112d6351ea6a1ab25008c1/torchvision-0.22.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:810ea4af3bc63cf39e834f91f4218ff5999271caaffe2456247df905002bd6c0", size = 2512604, upload-time = "2025-04-23T14:41:56.515Z" }, + { url = "https://files.pythonhosted.org/packages/e7/9e/e898a377e674da47e95227f3d7be2c49550ce381eebd8c7831c1f8bb7d39/torchvision-0.22.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:6fbca169c690fa2b9b8c39c0ad76d5b8992296d0d03df01e11df97ce12b4e0ac", size = 7446399, upload-time = "2025-04-23T14:41:49.793Z" }, + { url = "https://files.pythonhosted.org/packages/c7/ec/2cdb90c6d9d61410b3df9ca67c210b60bf9b07aac31f800380b20b90386c/torchvision-0.22.0-cp310-cp310-win_amd64.whl", hash = "sha256:8c869df2e8e00f7b1d80a34439e6d4609b50fe3141032f50b38341ec2b59404e", size = 1716700, upload-time = "2025-04-23T14:42:03.562Z" }, + { url = "https://files.pythonhosted.org/packages/b1/43/28bc858b022f6337326d75f4027d2073aad5432328f01ee1236d847f1b82/torchvision-0.22.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:191ea28321fc262d8aa1a7fe79c41ff2848864bf382f9f6ea45c41dde8313792", size = 1947828, upload-time = "2025-04-23T14:42:00.439Z" }, + { url = "https://files.pythonhosted.org/packages/7e/71/ce9a303b94e64fe25d534593522ffc76848c4e64c11e4cbe9f6b8d537210/torchvision-0.22.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:6c5620e10ffe388eb6f4744962106ed7cf1508d26e6fdfa0c10522d3249aea24", size = 2514016, upload-time = "2025-04-23T14:41:48.566Z" }, + { url = "https://files.pythonhosted.org/packages/09/42/6908bff012a1dcc4fc515e52339652d7f488e208986542765c02ea775c2f/torchvision-0.22.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:ce292701c77c64dd3935e3e31c722c3b8b176a75f76dc09b804342efc1db5494", size = 7447546, upload-time = "2025-04-23T14:41:47.297Z" }, + { url = "https://files.pythonhosted.org/packages/e4/cf/8f9305cc0ea26badbbb3558ecae54c04a245429f03168f7fad502f8a5b25/torchvision-0.22.0-cp311-cp311-win_amd64.whl", hash = "sha256:e4017b5685dbab4250df58084f07d95e677b2f3ed6c2e507a1afb8eb23b580ca", size = 1716472, upload-time = "2025-04-23T14:42:01.999Z" }, + { url = "https://files.pythonhosted.org/packages/cb/ea/887d1d61cf4431a46280972de665f350af1898ce5006cd046326e5d0a2f2/torchvision-0.22.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:31c3165418fe21c3d81fe3459e51077c2f948801b8933ed18169f54652796a0f", size = 1947826, upload-time = "2025-04-23T14:41:59.188Z" }, + { url = "https://files.pythonhosted.org/packages/72/ef/21f8b6122e13ae045b8e49658029c695fd774cd21083b3fa5c3f9c5d3e35/torchvision-0.22.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:8f116bc82e0c076e70ba7776e611ed392b9666aa443662e687808b08993d26af", size = 2514571, upload-time = "2025-04-23T14:41:53.458Z" }, + { url = "https://files.pythonhosted.org/packages/7c/48/5f7617f6c60d135f86277c53f9d5682dfa4e66f4697f505f1530e8b69fb1/torchvision-0.22.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:ce4dc334ebd508de2c534817c9388e928bc2500cf981906ae8d6e2ca3bf4727a", size = 7446522, upload-time = "2025-04-23T14:41:34.9Z" }, + { url = "https://files.pythonhosted.org/packages/99/94/a015e93955f5d3a68689cc7c385a3cfcd2d62b84655d18b61f32fb04eb67/torchvision-0.22.0-cp312-cp312-win_amd64.whl", hash = "sha256:24b8c9255c209ca419cc7174906da2791c8b557b75c23496663ec7d73b55bebf", size = 1716664, upload-time = "2025-04-23T14:41:58.019Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2a/9b34685599dcb341d12fc2730055155623db7a619d2415a8d31f17050952/torchvision-0.22.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ece17995857dd328485c9c027c0b20ffc52db232e30c84ff6c95ab77201112c5", size = 1947823, upload-time = "2025-04-23T14:41:39.956Z" }, + { url = "https://files.pythonhosted.org/packages/77/77/88f64879483d66daf84f1d1c4d5c31ebb08e640411139042a258d5f7dbfe/torchvision-0.22.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:471c6dd75bb984c6ebe4f60322894a290bf3d4b195e769d80754f3689cd7f238", size = 2471592, upload-time = "2025-04-23T14:41:54.991Z" }, + { url = "https://files.pythonhosted.org/packages/f7/82/2f813eaae7c1fae1f9d9e7829578f5a91f39ef48d6c1c588a8900533dd3d/torchvision-0.22.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:2b839ac0610a38f56bef115ee5b9eaca5f9c2da3c3569a68cc62dbcc179c157f", size = 7446333, upload-time = "2025-04-23T14:41:36.603Z" }, + { url = "https://files.pythonhosted.org/packages/58/19/ca7a4f8907a56351dfe6ae0a708f4e6b3569b5c61d282e3e7f61cf42a4ce/torchvision-0.22.0-cp313-cp313-win_amd64.whl", hash = "sha256:4ada1c08b2f761443cd65b7c7b4aec9e2fc28f75b0d4e1b1ebc9d3953ebccc4d", size = 1716693, upload-time = "2025-04-23T14:41:41.031Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a7/f43e9c8d13118b4ffbaebea664c9338ab20fa115a908125afd2238ff16e7/torchvision-0.22.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cdc96daa4658b47ce9384154c86ed1e70cba9d972a19f5de6e33f8f94a626790", size = 2137621, upload-time = "2025-04-23T14:41:51.427Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9a/2b59f5758ba7e3f23bc84e16947493bbce97392ec6d18efba7bdf0a3b10e/torchvision-0.22.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:753d3c84eeadd5979a33b3b73a25ecd0aa4af44d6b45ed2c70d44f5e0ac68312", size = 2476555, upload-time = "2025-04-23T14:41:38.357Z" }, + { url = "https://files.pythonhosted.org/packages/7d/40/a7bc2ab9b1e56d10a7fd9ae83191bb425fa308caa23d148f1c568006e02c/torchvision-0.22.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:b30e3ed29e4a61f7499bca50f57d8ebd23dfc52b14608efa17a534a55ee59a03", size = 7617924, upload-time = "2025-04-23T14:41:42.709Z" }, + { url = "https://files.pythonhosted.org/packages/c1/7b/30d423bdb2546250d719d7821aaf9058cc093d165565b245b159c788a9dd/torchvision-0.22.0-cp313-cp313t-win_amd64.whl", hash = "sha256:e5d680162694fac4c8a374954e261ddfb4eb0ce103287b0f693e4e9c579ef957", size = 1638621, upload-time = "2025-04-23T14:41:46.06Z" }, +] + +[[package]] +name = "tornado" +version = "6.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/51/89/c72771c81d25d53fe33e3dca61c233b665b2780f21820ba6fd2c6793c12b/tornado-6.5.1.tar.gz", hash = "sha256:84ceece391e8eb9b2b95578db65e920d2a61070260594819589609ba9bc6308c", size = 509934, upload-time = "2025-05-22T18:15:38.788Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/89/f4532dee6843c9e0ebc4e28d4be04c67f54f60813e4bf73d595fe7567452/tornado-6.5.1-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:d50065ba7fd11d3bd41bcad0825227cc9a95154bad83239357094c36708001f7", size = 441948, upload-time = "2025-05-22T18:15:20.862Z" }, + { url = "https://files.pythonhosted.org/packages/15/9a/557406b62cffa395d18772e0cdcf03bed2fff03b374677348eef9f6a3792/tornado-6.5.1-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:9e9ca370f717997cb85606d074b0e5b247282cf5e2e1611568b8821afe0342d6", size = 440112, upload-time = "2025-05-22T18:15:22.591Z" }, + { url = "https://files.pythonhosted.org/packages/55/82/7721b7319013a3cf881f4dffa4f60ceff07b31b394e459984e7a36dc99ec/tornado-6.5.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b77e9dfa7ed69754a54c89d82ef746398be82f749df69c4d3abe75c4d1ff4888", size = 443672, upload-time = "2025-05-22T18:15:24.027Z" }, + { url = "https://files.pythonhosted.org/packages/7d/42/d11c4376e7d101171b94e03cef0cbce43e823ed6567ceda571f54cf6e3ce/tornado-6.5.1-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:253b76040ee3bab8bcf7ba9feb136436a3787208717a1fb9f2c16b744fba7331", size = 443019, upload-time = "2025-05-22T18:15:25.735Z" }, + { url = "https://files.pythonhosted.org/packages/7d/f7/0c48ba992d875521ac761e6e04b0a1750f8150ae42ea26df1852d6a98942/tornado-6.5.1-cp39-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:308473f4cc5a76227157cdf904de33ac268af770b2c5f05ca6c1161d82fdd95e", size = 443252, upload-time = "2025-05-22T18:15:27.499Z" }, + { url = "https://files.pythonhosted.org/packages/89/46/d8d7413d11987e316df4ad42e16023cd62666a3c0dfa1518ffa30b8df06c/tornado-6.5.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:caec6314ce8a81cf69bd89909f4b633b9f523834dc1a352021775d45e51d9401", size = 443930, upload-time = "2025-05-22T18:15:29.299Z" }, + { url = "https://files.pythonhosted.org/packages/78/b2/f8049221c96a06df89bed68260e8ca94beca5ea532ffc63b1175ad31f9cc/tornado-6.5.1-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:13ce6e3396c24e2808774741331638ee6c2f50b114b97a55c5b442df65fd9692", size = 443351, upload-time = "2025-05-22T18:15:31.038Z" }, + { url = "https://files.pythonhosted.org/packages/76/ff/6a0079e65b326cc222a54720a748e04a4db246870c4da54ece4577bfa702/tornado-6.5.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5cae6145f4cdf5ab24744526cc0f55a17d76f02c98f4cff9daa08ae9a217448a", size = 443328, upload-time = "2025-05-22T18:15:32.426Z" }, + { url = "https://files.pythonhosted.org/packages/49/18/e3f902a1d21f14035b5bc6246a8c0f51e0eef562ace3a2cea403c1fb7021/tornado-6.5.1-cp39-abi3-win32.whl", hash = "sha256:e0a36e1bc684dca10b1aa75a31df8bdfed656831489bc1e6a6ebed05dc1ec365", size = 444396, upload-time = "2025-05-22T18:15:34.205Z" }, + { url = "https://files.pythonhosted.org/packages/7b/09/6526e32bf1049ee7de3bebba81572673b19a2a8541f795d887e92af1a8bc/tornado-6.5.1-cp39-abi3-win_amd64.whl", hash = "sha256:908e7d64567cecd4c2b458075589a775063453aeb1d2a1853eedb806922f568b", size = 444840, upload-time = "2025-05-22T18:15:36.1Z" }, + { url = "https://files.pythonhosted.org/packages/55/a7/535c44c7bea4578e48281d83c615219f3ab19e6abc67625ef637c73987be/tornado-6.5.1-cp39-abi3-win_arm64.whl", hash = "sha256:02420a0eb7bf617257b9935e2b754d1b63897525d8a289c9d65690d580b4dcf7", size = 443596, upload-time = "2025-05-22T18:15:37.433Z" }, +] + +[[package]] +name = "tox" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cachetools" }, + { name = "chardet" }, + { name = "colorama" }, + { name = "filelock" }, + { name = "packaging" }, + { name = "platformdirs" }, + { name = "pluggy" }, + { name = "pyproject-api" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fd/3c/dcec0c00321a107f7f697fd00754c5112572ea6dcacb40b16d8c3eea7c37/tox-4.26.0.tar.gz", hash = "sha256:a83b3b67b0159fa58e44e646505079e35a43317a62d2ae94725e0586266faeca", size = 197260, upload-time = "2025-05-13T15:04:28.481Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/14/f58b4087cf248b18c795b5c838c7a8d1428dfb07cb468dad3ec7f54041ab/tox-4.26.0-py3-none-any.whl", hash = "sha256:75f17aaf09face9b97bd41645028d9f722301e912be8b4c65a3f938024560224", size = 172761, upload-time = "2025-05-13T15:04:26.207Z" }, +] + +[[package]] +name = "tox-uv" +version = "1.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "tox" }, + { name = "uv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7e/da/37790b4a176f05b0ec7a699f54979078fc726f743640aa5c10c551c27edb/tox_uv-1.26.0.tar.gz", hash = "sha256:5045880c467eed58a98f7eaa7fe286b7ef688e2c56f2123d53e275011495c381", size = 21523, upload-time = "2025-05-27T14:51:42.702Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/b8/04c5cb83da072a3f96d357d68a551f5e97e162573c2011a09437df995811/tox_uv-1.26.0-py3-none-any.whl", hash = "sha256:894b2e7274fd6131c3bd1012813edc858753cad67727050c21cd973a08e691c8", size = 16562, upload-time = "2025-05-27T14:51:40.803Z" }, +] + +[[package]] +name = "tqdm" +version = "4.67.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a8/4b/29b4ef32e036bb34e4ab51796dd745cdba7ed47ad142a9f4a1eb8e0c744d/tqdm-4.67.1.tar.gz", hash = "sha256:f8aef9c52c08c13a65f30ea34f4e5aac3fd1a34959879d7e59e63027286627f2", size = 169737, upload-time = "2024-11-24T20:12:22.481Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2", size = 78540, upload-time = "2024-11-24T20:12:19.698Z" }, +] + +[[package]] +name = "traitlets" +version = "5.14.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/eb/79/72064e6a701c2183016abbbfedaba506d81e30e232a68c9f0d6f6fcd1574/traitlets-5.14.3.tar.gz", hash = "sha256:9ed0579d3502c94b4b3732ac120375cda96f923114522847de4b3bb98b96b6b7", size = 161621, upload-time = "2024-04-19T11:11:49.746Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/c0/8f5d070730d7836adc9c9b6408dec68c6ced86b304a9b26a14df072a6e8c/traitlets-5.14.3-py3-none-any.whl", hash = "sha256:b74e89e397b1ed28cc831db7aea759ba6640cb3de13090ca145426688ff1ac4f", size = 85359, upload-time = "2024-04-19T11:11:46.763Z" }, +] + +[[package]] +name = "triton" +version = "3.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "setuptools", marker = "(platform_machine != 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux')" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/04/d54d3a6d077c646624dc9461b0059e23fd5d30e0dbe67471e3654aec81f9/triton-3.3.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fad99beafc860501d7fcc1fb7045d9496cbe2c882b1674640304949165a916e7", size = 156441993, upload-time = "2025-04-09T20:27:25.107Z" }, + { url = "https://files.pythonhosted.org/packages/3c/c5/4874a81131cc9e934d88377fbc9d24319ae1fb540f3333b4e9c696ebc607/triton-3.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3161a2bf073d6b22c4e2f33f951f3e5e3001462b2570e6df9cd57565bdec2984", size = 156528461, upload-time = "2025-04-09T20:27:32.599Z" }, + { url = "https://files.pythonhosted.org/packages/11/53/ce18470914ab6cfbec9384ee565d23c4d1c55f0548160b1c7b33000b11fd/triton-3.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b68c778f6c4218403a6bd01be7484f6dc9e20fe2083d22dd8aef33e3b87a10a3", size = 156504509, upload-time = "2025-04-09T20:27:40.413Z" }, + { url = "https://files.pythonhosted.org/packages/7d/74/4bf2702b65e93accaa20397b74da46fb7a0356452c1bb94dbabaf0582930/triton-3.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47bc87ad66fa4ef17968299acacecaab71ce40a238890acc6ad197c3abe2b8f1", size = 156516468, upload-time = "2025-04-09T20:27:48.196Z" }, + { url = "https://files.pythonhosted.org/packages/0a/93/f28a696fa750b9b608baa236f8225dd3290e5aff27433b06143adc025961/triton-3.3.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ce4700fc14032af1e049005ae94ba908e71cd6c2df682239aed08e49bc71b742", size = 156580729, upload-time = "2025-04-09T20:27:55.424Z" }, +] + +[[package]] +name = "typer" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "rich" }, + { name = "shellingham" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c5/8c/7d682431efca5fd290017663ea4588bf6f2c6aad085c7f108c5dbc316e70/typer-0.16.0.tar.gz", hash = "sha256:af377ffaee1dbe37ae9440cb4e8f11686ea5ce4e9bae01b84ae7c63b87f1dd3b", size = 102625, upload-time = "2025-05-26T14:30:31.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/42/3efaf858001d2c2913de7f354563e3a3a2f0decae3efe98427125a8f441e/typer-0.16.0-py3-none-any.whl", hash = "sha256:1f79bed11d4d02d4310e3c1b7ba594183bcedb0ac73b27a9e5f28f6fb5b98855", size = 46317, upload-time = "2025-05-26T14:30:30.523Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.13.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/37/23083fcd6e35492953e8d2aaaa68b860eb422b34627b13f2ce3eb6106061/typing_extensions-4.13.2.tar.gz", hash = "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef", size = 106967, upload-time = "2025-04-10T14:19:05.416Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/54/b1ae86c0973cc6f0210b53d508ca3641fb6d0c56823f288d108bc7ab3cc8/typing_extensions-4.13.2-py3-none-any.whl", hash = "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c", size = 45806, upload-time = "2025-04-10T14:19:03.967Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f8/b1/0c11f5058406b3af7609f121aaa6b609744687f1d158b3c3a5bf4cc94238/typing_inspection-0.4.1.tar.gz", hash = "sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28", size = 75726, upload-time = "2025-05-21T18:55:23.885Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/69/cd203477f944c353c31bade965f880aa1061fd6bf05ded0726ca845b6ff7/typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51", size = 14552, upload-time = "2025-05-21T18:55:22.152Z" }, +] + +[[package]] +name = "tzdata" +version = "2025.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/95/32/1a225d6164441be760d75c2c42e2780dc0873fe382da3e98a2e1e48361e5/tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9", size = 196380, upload-time = "2025-03-23T13:54:43.652Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839, upload-time = "2025-03-23T13:54:41.845Z" }, +] + +[[package]] +name = "urllib3" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8a/78/16493d9c386d8e60e442a35feac5e00f0913c0f4b7c217c11e8ec2ff53e0/urllib3-2.4.0.tar.gz", hash = "sha256:414bc6535b787febd7567804cc015fee39daab8ad86268f1310a9250697de466", size = 390672, upload-time = "2025-04-10T15:23:39.232Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6b/11/cc635220681e93a0183390e26485430ca2c7b5f9d33b15c74c2861cb8091/urllib3-2.4.0-py3-none-any.whl", hash = "sha256:4e16665048960a0900c702d4a66415956a584919c03361cac9f1df5c5dd7e813", size = 128680, upload-time = "2025-04-10T15:23:37.377Z" }, +] + +[[package]] +name = "uv" +version = "0.7.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0c/4f/c26b354fc791fb716a990f6b0147c0b5d69351400030654827fb920fd79b/uv-0.7.8.tar.gz", hash = "sha256:a59d6749587946d63d371170d8f69d168ca8f4eade5cf880ad3be2793ea29c77", size = 3258494, upload-time = "2025-05-24T00:28:18.241Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/48/dd73c6a9b7b18dc1784b243cd5a93c14db34876c5a5cbb215e00be285e05/uv-0.7.8-py3-none-linux_armv6l.whl", hash = "sha256:ff1b7e4bc8a1d260062782ad34d12ce0df068df01d4a0f61d0ddc20aba1a5688", size = 16741809, upload-time = "2025-05-24T00:27:20.873Z" }, + { url = "https://files.pythonhosted.org/packages/b4/bd/0bc26f1f4f476cff93c8ce2d258819b10b9a4e41a9825405788ef25a2300/uv-0.7.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b83866be6a69f680f3d2e36b3befd2661b5596e59e575e266e7446b28efa8319", size = 16836506, upload-time = "2025-05-24T00:27:25.229Z" }, + { url = "https://files.pythonhosted.org/packages/26/28/1573e22b5f109f7779ddf64cb11e8e475ac05cf94e6b79ad3a4494c8c39c/uv-0.7.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:f749b58a5c348c455083781c92910e49b4ddba85c591eb67e97a8b84db03ef9b", size = 15642479, upload-time = "2025-05-24T00:27:28.866Z" }, + { url = "https://files.pythonhosted.org/packages/ad/f1/3d403896ea1edeea9109cab924e6a724ed7f5fbdabe8e5e9f3e3aa2be95a/uv-0.7.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:c058ee0f8c20b0942bd9f5c83a67b46577fa79f5691df8867b8e0f2d74cbadb1", size = 16043352, upload-time = "2025-05-24T00:27:31.911Z" }, + { url = "https://files.pythonhosted.org/packages/c7/2e/a914e491af320be503db26ff57f1b328738d1d7419cdb690e6e31d87ae16/uv-0.7.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2a07bdf9d6aadef40dd4edbe209bca698a3d3244df5285d40d2125f82455519c", size = 16413446, upload-time = "2025-05-24T00:27:35.363Z" }, + { url = "https://files.pythonhosted.org/packages/c3/cc/a396870530db7661eac080d276eba25df1b6c930f50c721f8402370acd12/uv-0.7.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:13af6b94563f25bdca6bb73e294648af9c0b165af5bb60f0c913ab125ec45e06", size = 17188599, upload-time = "2025-05-24T00:27:38.979Z" }, + { url = "https://files.pythonhosted.org/packages/d0/96/299bd3895d630e28593dcc54f4c4dbd72e12b557288c6d153987bbd62f34/uv-0.7.8-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:4acc09c06d6cf7a27e0f1de4edb8c1698b8a3ffe34f322b10f4c145989e434b9", size = 18105049, upload-time = "2025-05-24T00:27:42.194Z" }, + { url = "https://files.pythonhosted.org/packages/8f/a4/9fa0b6a4540950fe7fa66d37c44228d6ad7bb6d42f66e16f4f96e20fd50c/uv-0.7.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9221a9679f2ffd031b71b735b84f58d5a2f1adf9bfa59c8e82a5201dad7db466", size = 17777603, upload-time = "2025-05-24T00:27:45.695Z" }, + { url = "https://files.pythonhosted.org/packages/d7/62/988cca0f1723406ff22edd6a9fb5e3e1d4dd0af103d8c3a64effadc685fd/uv-0.7.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:409cee21edcaf4a7c714893656ab4dd0814a15659cb4b81c6929cbb75cd2d378", size = 22222113, upload-time = "2025-05-24T00:27:49.172Z" }, + { url = "https://files.pythonhosted.org/packages/06/36/0e7943d9415560aa9fdd775d0bb4b9c06b69c543f0647210e5b84776658b/uv-0.7.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:81ac0bb371979f48d1293f9c1bee691680ea6a724f16880c8f76718f5ff50049", size = 17454597, upload-time = "2025-05-24T00:27:52.478Z" }, + { url = "https://files.pythonhosted.org/packages/bb/70/666be8dbc6a49e1a096f4577d69c4e6f78b3d9228fa2844d1bece21f5cd0/uv-0.7.8-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:3c620cecd6f3cdab59b316f41c2b1c4d1b709d9d5226cadeec370cfeed56f80c", size = 16335744, upload-time = "2025-05-24T00:27:55.657Z" }, + { url = "https://files.pythonhosted.org/packages/24/a5/c1fbffc8b62121c0d07aa66e7e5135065ff881ebb85ba307664125f4c51c/uv-0.7.8-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:0c691090ff631dde788c8f4f1b1ea20f9deb9d805289796dcf10bc4a144a817e", size = 16439468, upload-time = "2025-05-24T00:27:58.599Z" }, + { url = "https://files.pythonhosted.org/packages/65/95/a079658721b88d483c97a1765f9fd4f1b8b4fa601f2889d86824244861f2/uv-0.7.8-py3-none-musllinux_1_1_i686.whl", hash = "sha256:4a117fe3806ba4ebb9c68fdbf91507e515a883dfab73fa863df9bc617d6de7a3", size = 16740156, upload-time = "2025-05-24T00:28:01.657Z" }, + { url = "https://files.pythonhosted.org/packages/14/69/a2d110786c4cf093d788cfcde9e99c634af087555f0bf9ceafc009d051ed/uv-0.7.8-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:91d022235b39e59bab4bce7c4b634dc67e16fa89725cdfb2149a6ef7eaf6d784", size = 17569652, upload-time = "2025-05-24T00:28:04.903Z" }, + { url = "https://files.pythonhosted.org/packages/6f/56/db6db0dc20114b76eb48dbd5167a26a2ebe51e8b604b4e84c5ef84ef4103/uv-0.7.8-py3-none-win32.whl", hash = "sha256:6ebe252f34c50b09b7f641f8e603d7b627f579c76f181680c757012b808be456", size = 16958006, upload-time = "2025-05-24T00:28:07.996Z" }, + { url = "https://files.pythonhosted.org/packages/4b/80/5c78a9adc50fa3b7cca3a0c1245dff8c74d906ab53c3503b1f8133243930/uv-0.7.8-py3-none-win_amd64.whl", hash = "sha256:b5b62ca8a1bea5fdbf8a6372eabb03376dffddb5d139688bbb488c0719fa52fc", size = 18457129, upload-time = "2025-05-24T00:28:11.844Z" }, + { url = "https://files.pythonhosted.org/packages/15/52/fd76b44942ac308e1dbbebea8b23de67a0f891a54d5e51346c3c3564dd9b/uv-0.7.8-py3-none-win_arm64.whl", hash = "sha256:ad79388b0c6eff5383b963d8d5ddcb7fbb24b0b82bf5d0c8b1bdbfbe445cb868", size = 17177058, upload-time = "2025-05-24T00:28:15.561Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a6/ae/9bbb19b9e1c450cf9ecaef06463e40234d98d95bf572fab11b4f19ae5ded/uvicorn-0.34.2.tar.gz", hash = "sha256:0e929828f6186353a80b58ea719861d2629d766293b6d19baf086ba31d4f3328", size = 76815, upload-time = "2025-04-19T06:02:50.101Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b1/4b/4cef6ce21a2aaca9d852a6e84ef4f135d99fcd74fa75105e2fc0c8308acd/uvicorn-0.34.2-py3-none-any.whl", hash = "sha256:deb49af569084536d269fe0a6d67e3754f104cf03aba7c11c40f01aadf33c403", size = 62483, upload-time = "2025-04-19T06:02:48.42Z" }, +] + +[[package]] +name = "virtualenv" +version = "20.31.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/56/2c/444f465fb2c65f40c3a104fd0c495184c4f2336d65baf398e3c75d72ea94/virtualenv-20.31.2.tar.gz", hash = "sha256:e10c0a9d02835e592521be48b332b6caee6887f332c111aa79a09b9e79efc2af", size = 6076316, upload-time = "2025-05-08T17:58:23.811Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/40/b1c265d4b2b62b58576588510fc4d1fe60a86319c8de99fd8e9fec617d2c/virtualenv-20.31.2-py3-none-any.whl", hash = "sha256:36efd0d9650ee985f0cad72065001e66d49a6f24eb44d98980f630686243cf11", size = 6057982, upload-time = "2025-05-08T17:58:21.15Z" }, +] + +[[package]] +name = "watchdog" +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/56/90994d789c61df619bfc5ce2ecdabd5eeff564e1eb47512bd01b5e019569/watchdog-6.0.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d1cdb490583ebd691c012b3d6dae011000fe42edb7a82ece80965b42abd61f26", size = 96390, upload-time = "2024-11-01T14:06:24.793Z" }, + { url = "https://files.pythonhosted.org/packages/55/46/9a67ee697342ddf3c6daa97e3a587a56d6c4052f881ed926a849fcf7371c/watchdog-6.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bc64ab3bdb6a04d69d4023b29422170b74681784ffb9463ed4870cf2f3e66112", size = 88389, upload-time = "2024-11-01T14:06:27.112Z" }, + { url = "https://files.pythonhosted.org/packages/44/65/91b0985747c52064d8701e1075eb96f8c40a79df889e59a399453adfb882/watchdog-6.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c897ac1b55c5a1461e16dae288d22bb2e412ba9807df8397a635d88f671d36c3", size = 89020, upload-time = "2024-11-01T14:06:29.876Z" }, + { url = "https://files.pythonhosted.org/packages/e0/24/d9be5cd6642a6aa68352ded4b4b10fb0d7889cb7f45814fb92cecd35f101/watchdog-6.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6eb11feb5a0d452ee41f824e271ca311a09e250441c262ca2fd7ebcf2461a06c", size = 96393, upload-time = "2024-11-01T14:06:31.756Z" }, + { url = "https://files.pythonhosted.org/packages/63/7a/6013b0d8dbc56adca7fdd4f0beed381c59f6752341b12fa0886fa7afc78b/watchdog-6.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ef810fbf7b781a5a593894e4f439773830bdecb885e6880d957d5b9382a960d2", size = 88392, upload-time = "2024-11-01T14:06:32.99Z" }, + { url = "https://files.pythonhosted.org/packages/d1/40/b75381494851556de56281e053700e46bff5b37bf4c7267e858640af5a7f/watchdog-6.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:afd0fe1b2270917c5e23c2a65ce50c2a4abb63daafb0d419fde368e272a76b7c", size = 89019, upload-time = "2024-11-01T14:06:34.963Z" }, + { url = "https://files.pythonhosted.org/packages/39/ea/3930d07dafc9e286ed356a679aa02d777c06e9bfd1164fa7c19c288a5483/watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948", size = 96471, upload-time = "2024-11-01T14:06:37.745Z" }, + { url = "https://files.pythonhosted.org/packages/12/87/48361531f70b1f87928b045df868a9fd4e253d9ae087fa4cf3f7113be363/watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860", size = 88449, upload-time = "2024-11-01T14:06:39.748Z" }, + { url = "https://files.pythonhosted.org/packages/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0", size = 89054, upload-time = "2024-11-01T14:06:41.009Z" }, + { url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480, upload-time = "2024-11-01T14:06:42.952Z" }, + { url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451, upload-time = "2024-11-01T14:06:45.084Z" }, + { url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057, upload-time = "2024-11-01T14:06:47.324Z" }, + { url = "https://files.pythonhosted.org/packages/30/ad/d17b5d42e28a8b91f8ed01cb949da092827afb9995d4559fd448d0472763/watchdog-6.0.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:c7ac31a19f4545dd92fc25d200694098f42c9a8e391bc00bdd362c5736dbf881", size = 87902, upload-time = "2024-11-01T14:06:53.119Z" }, + { url = "https://files.pythonhosted.org/packages/5c/ca/c3649991d140ff6ab67bfc85ab42b165ead119c9e12211e08089d763ece5/watchdog-6.0.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:9513f27a1a582d9808cf21a07dae516f0fab1cf2d7683a742c498b93eedabb11", size = 88380, upload-time = "2024-11-01T14:06:55.19Z" }, + { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" }, + { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" }, + { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" }, + { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload-time = "2024-11-01T14:07:03.893Z" }, + { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload-time = "2024-11-01T14:07:05.189Z" }, + { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload-time = "2024-11-01T14:07:06.376Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" }, + { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload-time = "2024-11-01T14:07:09.525Z" }, + { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" }, + { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" }, +] + +[[package]] +name = "wcmatch" +version = "10.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "bracex" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/41/ab/b3a52228538ccb983653c446c1656eddf1d5303b9cb8b9aef6a91299f862/wcmatch-10.0.tar.gz", hash = "sha256:e72f0de09bba6a04e0de70937b0cf06e55f36f37b3deb422dfaf854b867b840a", size = 115578, upload-time = "2024-09-26T18:39:52.505Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/df/4ee467ab39cc1de4b852c212c1ed3becfec2e486a51ac1ce0091f85f38d7/wcmatch-10.0-py3-none-any.whl", hash = "sha256:0dd927072d03c0a6527a20d2e6ad5ba8d0380e60870c383bc533b71744df7b7a", size = 39347, upload-time = "2024-09-26T18:39:51.002Z" }, +] + +[[package]] +name = "wcwidth" +version = "0.2.13" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6c/63/53559446a878410fc5a5974feb13d31d78d752eb18aeba59c7fef1af7598/wcwidth-0.2.13.tar.gz", hash = "sha256:72ea0c06399eb286d978fdedb6923a9eb47e1c486ce63e9b4e64fc18303972b5", size = 101301, upload-time = "2024-01-06T02:10:57.829Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/84/fd2ba7aafacbad3c4201d395674fc6348826569da3c0937e75505ead3528/wcwidth-0.2.13-py2.py3-none-any.whl", hash = "sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859", size = 34166, upload-time = "2024-01-06T02:10:55.763Z" }, +] + +[[package]] +name = "websockets" +version = "15.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/da/6462a9f510c0c49837bbc9345aca92d767a56c1fb2939e1579df1e1cdcf7/websockets-15.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d63efaa0cd96cf0c5fe4d581521d9fa87744540d4bc999ae6e08595a1014b45b", size = 175423, upload-time = "2025-03-05T20:01:35.363Z" }, + { url = "https://files.pythonhosted.org/packages/1c/9f/9d11c1a4eb046a9e106483b9ff69bce7ac880443f00e5ce64261b47b07e7/websockets-15.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ac60e3b188ec7574cb761b08d50fcedf9d77f1530352db4eef1707fe9dee7205", size = 173080, upload-time = "2025-03-05T20:01:37.304Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4f/b462242432d93ea45f297b6179c7333dd0402b855a912a04e7fc61c0d71f/websockets-15.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5756779642579d902eed757b21b0164cd6fe338506a8083eb58af5c372e39d9a", size = 173329, upload-time = "2025-03-05T20:01:39.668Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0c/6afa1f4644d7ed50284ac59cc70ef8abd44ccf7d45850d989ea7310538d0/websockets-15.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fdfe3e2a29e4db3659dbd5bbf04560cea53dd9610273917799f1cde46aa725e", size = 182312, upload-time = "2025-03-05T20:01:41.815Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d4/ffc8bd1350b229ca7a4db2a3e1c482cf87cea1baccd0ef3e72bc720caeec/websockets-15.0.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c2529b320eb9e35af0fa3016c187dffb84a3ecc572bcee7c3ce302bfeba52bf", size = 181319, upload-time = "2025-03-05T20:01:43.967Z" }, + { url = "https://files.pythonhosted.org/packages/97/3a/5323a6bb94917af13bbb34009fac01e55c51dfde354f63692bf2533ffbc2/websockets-15.0.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac1e5c9054fe23226fb11e05a6e630837f074174c4c2f0fe442996112a6de4fb", size = 181631, upload-time = "2025-03-05T20:01:46.104Z" }, + { url = "https://files.pythonhosted.org/packages/a6/cc/1aeb0f7cee59ef065724041bb7ed667b6ab1eeffe5141696cccec2687b66/websockets-15.0.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5df592cd503496351d6dc14f7cdad49f268d8e618f80dce0cd5a36b93c3fc08d", size = 182016, upload-time = "2025-03-05T20:01:47.603Z" }, + { url = "https://files.pythonhosted.org/packages/79/f9/c86f8f7af208e4161a7f7e02774e9d0a81c632ae76db2ff22549e1718a51/websockets-15.0.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0a34631031a8f05657e8e90903e656959234f3a04552259458aac0b0f9ae6fd9", size = 181426, upload-time = "2025-03-05T20:01:48.949Z" }, + { url = "https://files.pythonhosted.org/packages/c7/b9/828b0bc6753db905b91df6ae477c0b14a141090df64fb17f8a9d7e3516cf/websockets-15.0.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3d00075aa65772e7ce9e990cab3ff1de702aa09be3940d1dc88d5abf1ab8a09c", size = 181360, upload-time = "2025-03-05T20:01:50.938Z" }, + { url = "https://files.pythonhosted.org/packages/89/fb/250f5533ec468ba6327055b7d98b9df056fb1ce623b8b6aaafb30b55d02e/websockets-15.0.1-cp310-cp310-win32.whl", hash = "sha256:1234d4ef35db82f5446dca8e35a7da7964d02c127b095e172e54397fb6a6c256", size = 176388, upload-time = "2025-03-05T20:01:52.213Z" }, + { url = "https://files.pythonhosted.org/packages/1c/46/aca7082012768bb98e5608f01658ff3ac8437e563eca41cf068bd5849a5e/websockets-15.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:39c1fec2c11dc8d89bba6b2bf1556af381611a173ac2b511cf7231622058af41", size = 176830, upload-time = "2025-03-05T20:01:53.922Z" }, + { url = "https://files.pythonhosted.org/packages/9f/32/18fcd5919c293a398db67443acd33fde142f283853076049824fc58e6f75/websockets-15.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:823c248b690b2fd9303ba00c4f66cd5e2d8c3ba4aa968b2779be9532a4dad431", size = 175423, upload-time = "2025-03-05T20:01:56.276Z" }, + { url = "https://files.pythonhosted.org/packages/76/70/ba1ad96b07869275ef42e2ce21f07a5b0148936688c2baf7e4a1f60d5058/websockets-15.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678999709e68425ae2593acf2e3ebcbcf2e69885a5ee78f9eb80e6e371f1bf57", size = 173082, upload-time = "2025-03-05T20:01:57.563Z" }, + { url = "https://files.pythonhosted.org/packages/86/f2/10b55821dd40eb696ce4704a87d57774696f9451108cff0d2824c97e0f97/websockets-15.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d50fd1ee42388dcfb2b3676132c78116490976f1300da28eb629272d5d93e905", size = 173330, upload-time = "2025-03-05T20:01:59.063Z" }, + { url = "https://files.pythonhosted.org/packages/a5/90/1c37ae8b8a113d3daf1065222b6af61cc44102da95388ac0018fcb7d93d9/websockets-15.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d99e5546bf73dbad5bf3547174cd6cb8ba7273062a23808ffea025ecb1cf8562", size = 182878, upload-time = "2025-03-05T20:02:00.305Z" }, + { url = "https://files.pythonhosted.org/packages/8e/8d/96e8e288b2a41dffafb78e8904ea7367ee4f891dafc2ab8d87e2124cb3d3/websockets-15.0.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:66dd88c918e3287efc22409d426c8f729688d89a0c587c88971a0faa2c2f3792", size = 181883, upload-time = "2025-03-05T20:02:03.148Z" }, + { url = "https://files.pythonhosted.org/packages/93/1f/5d6dbf551766308f6f50f8baf8e9860be6182911e8106da7a7f73785f4c4/websockets-15.0.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8dd8327c795b3e3f219760fa603dcae1dcc148172290a8ab15158cf85a953413", size = 182252, upload-time = "2025-03-05T20:02:05.29Z" }, + { url = "https://files.pythonhosted.org/packages/d4/78/2d4fed9123e6620cbf1706c0de8a1632e1a28e7774d94346d7de1bba2ca3/websockets-15.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8fdc51055e6ff4adeb88d58a11042ec9a5eae317a0a53d12c062c8a8865909e8", size = 182521, upload-time = "2025-03-05T20:02:07.458Z" }, + { url = "https://files.pythonhosted.org/packages/e7/3b/66d4c1b444dd1a9823c4a81f50231b921bab54eee2f69e70319b4e21f1ca/websockets-15.0.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:693f0192126df6c2327cce3baa7c06f2a117575e32ab2308f7f8216c29d9e2e3", size = 181958, upload-time = "2025-03-05T20:02:09.842Z" }, + { url = "https://files.pythonhosted.org/packages/08/ff/e9eed2ee5fed6f76fdd6032ca5cd38c57ca9661430bb3d5fb2872dc8703c/websockets-15.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:54479983bd5fb469c38f2f5c7e3a24f9a4e70594cd68cd1fa6b9340dadaff7cf", size = 181918, upload-time = "2025-03-05T20:02:11.968Z" }, + { url = "https://files.pythonhosted.org/packages/d8/75/994634a49b7e12532be6a42103597b71098fd25900f7437d6055ed39930a/websockets-15.0.1-cp311-cp311-win32.whl", hash = "sha256:16b6c1b3e57799b9d38427dda63edcbe4926352c47cf88588c0be4ace18dac85", size = 176388, upload-time = "2025-03-05T20:02:13.32Z" }, + { url = "https://files.pythonhosted.org/packages/98/93/e36c73f78400a65f5e236cd376713c34182e6663f6889cd45a4a04d8f203/websockets-15.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:27ccee0071a0e75d22cb35849b1db43f2ecd3e161041ac1ee9d2352ddf72f065", size = 176828, upload-time = "2025-03-05T20:02:14.585Z" }, + { url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437, upload-time = "2025-03-05T20:02:16.706Z" }, + { url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096, upload-time = "2025-03-05T20:02:18.832Z" }, + { url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332, upload-time = "2025-03-05T20:02:20.187Z" }, + { url = "https://files.pythonhosted.org/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152, upload-time = "2025-03-05T20:02:22.286Z" }, + { url = "https://files.pythonhosted.org/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096, upload-time = "2025-03-05T20:02:24.368Z" }, + { url = "https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", size = 182523, upload-time = "2025-03-05T20:02:25.669Z" }, + { url = "https://files.pythonhosted.org/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790, upload-time = "2025-03-05T20:02:26.99Z" }, + { url = "https://files.pythonhosted.org/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165, upload-time = "2025-03-05T20:02:30.291Z" }, + { url = "https://files.pythonhosted.org/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160, upload-time = "2025-03-05T20:02:31.634Z" }, + { url = "https://files.pythonhosted.org/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395, upload-time = "2025-03-05T20:02:33.017Z" }, + { url = "https://files.pythonhosted.org/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841, upload-time = "2025-03-05T20:02:34.498Z" }, + { url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440, upload-time = "2025-03-05T20:02:36.695Z" }, + { url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098, upload-time = "2025-03-05T20:02:37.985Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329, upload-time = "2025-03-05T20:02:39.298Z" }, + { url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111, upload-time = "2025-03-05T20:02:40.595Z" }, + { url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054, upload-time = "2025-03-05T20:02:41.926Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496, upload-time = "2025-03-05T20:02:43.304Z" }, + { url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829, upload-time = "2025-03-05T20:02:48.812Z" }, + { url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217, upload-time = "2025-03-05T20:02:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195, upload-time = "2025-03-05T20:02:51.561Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393, upload-time = "2025-03-05T20:02:53.814Z" }, + { url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/d40f779fa16f74d3468357197af8d6ad07e7c5a27ea1ca74ceb38986f77a/websockets-15.0.1-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0c9e74d766f2818bb95f84c25be4dea09841ac0f734d1966f415e4edfc4ef1c3", size = 173109, upload-time = "2025-03-05T20:03:17.769Z" }, + { url = "https://files.pythonhosted.org/packages/bc/cd/5b887b8585a593073fd92f7c23ecd3985cd2c3175025a91b0d69b0551372/websockets-15.0.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:1009ee0c7739c08a0cd59de430d6de452a55e42d6b522de7aa15e6f67db0b8e1", size = 173343, upload-time = "2025-03-05T20:03:19.094Z" }, + { url = "https://files.pythonhosted.org/packages/fe/ae/d34f7556890341e900a95acf4886833646306269f899d58ad62f588bf410/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76d1f20b1c7a2fa82367e04982e708723ba0e7b8d43aa643d3dcd404d74f1475", size = 174599, upload-time = "2025-03-05T20:03:21.1Z" }, + { url = "https://files.pythonhosted.org/packages/71/e6/5fd43993a87db364ec60fc1d608273a1a465c0caba69176dd160e197ce42/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f29d80eb9a9263b8d109135351caf568cc3f80b9928bccde535c235de55c22d9", size = 174207, upload-time = "2025-03-05T20:03:23.221Z" }, + { url = "https://files.pythonhosted.org/packages/2b/fb/c492d6daa5ec067c2988ac80c61359ace5c4c674c532985ac5a123436cec/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b359ed09954d7c18bbc1680f380c7301f92c60bf924171629c5db97febb12f04", size = 174155, upload-time = "2025-03-05T20:03:25.321Z" }, + { url = "https://files.pythonhosted.org/packages/68/a1/dcb68430b1d00b698ae7a7e0194433bce4f07ded185f0ee5fb21e2a2e91e/websockets-15.0.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:cad21560da69f4ce7658ca2cb83138fb4cf695a2ba3e475e0559e05991aa8122", size = 176884, upload-time = "2025-03-05T20:03:27.934Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" }, +] + +[[package]] +name = "werkzeug" +version = "3.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9f/69/83029f1f6300c5fb2471d621ab06f6ec6b3324685a2ce0f9777fd4a8b71e/werkzeug-3.1.3.tar.gz", hash = "sha256:60723ce945c19328679790e3282cc758aa4a6040e4bb330f53d30fa546d44746", size = 806925, upload-time = "2024-11-08T15:52:18.093Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/24/ab44c871b0f07f491e5d2ad12c9bd7358e527510618cb1b803a88e986db1/werkzeug-3.1.3-py3-none-any.whl", hash = "sha256:54b78bf3716d19a65be4fceccc0d1d7b89e608834989dfae50ea87564639213e", size = 224498, upload-time = "2024-11-08T15:52:16.132Z" }, +] + +[[package]] +name = "yacs" +version = "0.1.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/44/3e/4a45cb0738da6565f134c01d82ba291c746551b5bc82e781ec876eb20909/yacs-0.1.8.tar.gz", hash = "sha256:efc4c732942b3103bea904ee89af98bcd27d01f0ac12d8d4d369f1e7a2914384", size = 11100, upload-time = "2020-08-10T16:37:47.755Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/4f/fe9a4d472aa867878ce3bb7efb16654c5d63672b86dc0e6e953a67018433/yacs-0.1.8-py3-none-any.whl", hash = "sha256:99f893e30497a4b66842821bac316386f7bd5c4f47ad35c9073ef089aa33af32", size = 14747, upload-time = "2020-08-10T16:37:46.4Z" }, +]