diff --git a/docs/claude/runbook-mothbot-local-testing.md b/docs/claude/runbook-mothbot-local-testing.md new file mode 100644 index 00000000..bc36d8eb --- /dev/null +++ b/docs/claude/runbook-mothbot-local-testing.md @@ -0,0 +1,189 @@ +# Runbook: Testing Mothbot Pipelines Locally Against Antenna + +Exercises the `mothbot_insect_orders_2025` and `mothbot_panama_moths_2023` +pipelines end-to-end: ADC worker ↔ NATS ↔ Antenna Django ↔ Celery result +processor ↔ Postgres. This is the loop the author used while debugging the +YOLO-OBB integration. + +## Prerequisites + +- Local Antenna stack running: `antenna-django-1`, `antenna-celeryworker-1`, + NATS, Postgres, Redis (checked with `docker ps`). +- ADC on branch `worktree-mothbot-pipeline` in + `/home/michael/Projects/AMI/ami-data-companion/.claude/worktrees/mothbot-pipeline/`. +- `uv` installed; ADC's `.env` provides `AMI_ANTENNA_API_BASE_URL` and + `AMI_ANTENNA_API_AUTH_TOKEN`. +- Project 20 on Antenna has at least one collection with source images + (collection 10 = 16 starred images, collection 13 = 56 Panama images). + +## 1. Bring up the ADC worker + +The worker lives in tmux window 110. If it's already running, skip this. + +```bash +# From the mothbot-pipeline worktree +cd /home/michael/Projects/AMI/ami-data-companion/.claude/worktrees/mothbot-pipeline +uv run ami worker +``` + +Watch for two GPU workers to spin up and `Checking for jobs for pipelines:` +logs listing every registered slug. The two mothbot slugs must be in that +list; otherwise Antenna will refuse to dispatch to them. + +## 2. Register new pipelines with Antenna + +Antenna polls the ADC `/info` endpoint (not the NATS worker). When you add +a new pipeline, you need to run the FastAPI server briefly so Antenna can +sync it. + +### 2a. Start the ADC API server + +```bash +# In a separate terminal (NOT tmux 110) +cd /home/michael/Projects/AMI/ami-data-companion/.claude/worktrees/mothbot-pipeline +uv run uvicorn trapdata.api.api:app --host 0.0.0.0 --port 5001 \ + > /tmp/ami_api.log 2>&1 & +# Give it ~20s to load every classifier's weights +sleep 20 +curl -s http://localhost:5001/info | jq '.pipelines[].slug' +``` + +Confirm the new slug appears. The first startup will download classifier +weights; subsequent runs are cached under `~/.cache/torch/hub/models/`. + +### 2b. Point Antenna's ProcessingService at the host API server + +From inside the Django container, `localhost` is the container itself, not +your host. Use `host.docker.internal` instead (only needs to be set once +per Antenna DB): + +```bash +docker exec antenna-django-1 python manage.py shell -c " +from ami.ml.models import ProcessingService +svc = ProcessingService.objects.get(name='BEAST1 (LINUXVISION)') +svc.endpoint_url = 'http://host.docker.internal:5001' +svc.save() +svc.create_pipelines() +" +``` + +`create_pipelines()` reads `/info` and upserts Pipeline + Algorithm rows. + +### 2c. Known gotcha: stale pipeline metadata + +`create_pipelines()` inserts new rows but does **not** rename existing +Pipelines or remove algorithm links that disappeared from `/info`. If you +rename an existing pipeline (e.g. the Mothbot insect-orders rename in +`1057b8a`), Antenna's row keeps the old name/description until you clean +it up manually: + +```bash +docker exec antenna-django-1 python manage.py shell -c " +from ami.ml.models import Pipeline, Algorithm +p = Pipeline.objects.get(slug='mothbot_insect_orders_2025') +p.name = 'Mothbot YOLO + Insect Orders 2025' +p.description = '' +p.save() +# Also drop any orphan algorithm links no longer produced by /info +stale = Algorithm.objects.filter(key='insect_order_classifier_mothbot_yolo_detector').first() +if stale: + p.algorithms.remove(stale) +" +``` + +Skip this whole step for brand-new pipelines; only needed for renames. + +### 2d. Stop the API server + +```bash +pkill -f "uvicorn trapdata.api.api:app" +``` + +The ADC worker in tmux 110 handles actual processing via NATS; the HTTP +server is only needed for the `/info` sync. + +## 3. Trigger an end-to-end job + +Use Antenna's `test_ml_job_e2e` management command. `async_api` matches +production's NATS-dispatch path. + +```bash +docker exec antenna-django-1 python manage.py test_ml_job_e2e \ + --project 20 --collection 13 \ + --pipeline mothbot_panama_moths_2023 \ + --dispatch-mode async_api +``` + +The command blocks and prints progress every 2s. Expected output: + +``` +✅ Job completed successfully + Collect: 100.0% (SUCCESS) — Total Images: 55 + Process: 100.0% (SUCCESS) — Processed: 55, Failed: 0 + Results: 100.0% (SUCCESS) — Detections: 1015, Classifications: 1665 +🔗 Job ID: +``` + +For the insect-orders pipeline swap the slug; a smaller collection (10) +is fine to smoke-test. + +If any images fail, the full Rich traceback shows up in the ADC worker +logs (tmux 110). The `Batch N failed during processing:` line is where +to start reading — local variables are included, which is invaluable for +bbox-shape bugs. + +## 4. Verify detection quality + +The job-complete output only shows counts. To sanity-check that detections +aren't garbage (e.g. full-image boxes), pull bbox stats from Postgres: + +```bash +docker exec antenna-django-1 python manage.py shell -c " +from ami.main.models import Detection +from django.utils import timezone +import datetime +now = timezone.now() +recent = Detection.objects.filter( + created_at__gte=now - datetime.timedelta(minutes=5), + detection_algorithm__name__icontains='Mothbot', +) +sizes = [(d.bbox[2]-d.bbox[0], d.bbox[3]-d.bbox[1]) + for d in recent[:500] if d.bbox and len(d.bbox) == 4] +widths = sorted(s[0] for s in sizes) +heights = sorted(s[1] for s in sizes) +if widths: + print(f'count={len(widths)}') + print(f'w p10/p50/p90/max: {widths[len(widths)//10]:.0f}/{widths[len(widths)//2]:.0f}/{widths[len(widths)*9//10]:.0f}/{widths[-1]:.0f}') + print(f'h p10/p50/p90/max: {heights[len(heights)//10]:.0f}/{heights[len(heights)//2]:.0f}/{heights[len(heights)*9//10]:.0f}/{heights[-1]:.0f}') +" +``` + +Healthy numbers on 3280×2464 source images: median width/height around +200–300 px, p90 under ~1000 px. If the median is near image width (e.g. +1200+ on a 3280-wide image) the detector is producing full-image boxes +— a red flag that the RGB/BGR channel order or another preprocessing +detail has regressed (see `0726b23`). + +## 5. Iteration loop + +After a code change: + +1. Kill the worker: `Ctrl-C` in tmux 110. +2. If the change touches `/info` (pipeline list, descriptions, weights + URLs, `detector_cls`), redo step 2. +3. Restart worker: `uv run ami worker` in tmux 110. +4. Re-trigger the job (step 3). + +If the change only touches inference code paths (no new pipeline, no +algorithm metadata change), only steps 1, 3, and 4 are needed. + +## Appendix: Which collection for which test + +| Slug | Collection 10 (16 imgs, starred) | Collection 13 (56 imgs, Panama) | +|---|---|---| +| `mothbot_insect_orders_2025` | fastest smoke test | regression-size sanity check | +| `mothbot_panama_moths_2023` | fastest smoke test | realistic Panama load | + +The Panama collection is the more realistic load — Panama diopsis images +triggered the YOLO-OBB negative-coord bug originally because the moths +frequently touch the image edge. diff --git a/docs/superpowers/plans/2026-04-14-mothbot-detection-pipeline.md b/docs/superpowers/plans/2026-04-14-mothbot-detection-pipeline.md new file mode 100644 index 00000000..b840e406 --- /dev/null +++ b/docs/superpowers/plans/2026-04-14-mothbot-detection-pipeline.md @@ -0,0 +1,1454 @@ +# Mothbot YOLO Detection Pipeline — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a new API pipeline `mothbot_insect_orders_2025` that pairs the Mothbot YOLO11m-OBB detector with the existing `InsectOrderClassifier2025`, so users can run Mothbot-style detection followed by our ConvNeXt order classifier through the existing FastAPI `/process` endpoint. + +**Architecture:** Add a `detector_cls` class attribute on `APIMothClassifier` (default = existing `APIMothDetector`). The API's `/process` handler reads `Classifier.detector_cls` instead of a hardcoded reference, letting each pipeline pair a detector with a classifier. The new YOLO detector class lives alongside the existing FasterRCNN ones (ML + API split). `CLASSIFIER_CHOICES` is renamed to `PIPELINE_CHOICES` because the dict semantically maps to pipelines, not just classifiers. An optional `rotation: float | None` field is added to `DetectionResponse` to carry the YOLO OBB angle forward to a future species classifier — not used by consumers in this PR. + +**Tech Stack:** Python 3.10+, FastAPI, pydantic 2, SQLAlchemy, PyTorch 2.5+, `ultralytics>=8.3` (new AGPL-3 dep), existing `InferenceBaseClass` pattern, `uv` for deps. + +**Spec:** `docs/superpowers/specs/2026-04-14-mothbot-detection-pipeline-design.md` + +--- + +## Before starting (operator step) + +Upload the YOLO weights file to Arbutus. **This is a one-time operator action; no code task runs it.** Skip if the URL already resolves. + +```bash +# From the worktree root: +AWS_PROFILE=ami python3 -c " +import boto3 +from botocore.config import Config + +s3 = boto3.client( + 's3', + endpoint_url='https://object-arbutus.cloud.computecanada.ca', + config=Config(request_checksum_calculation='when_required'), +) +s3.upload_file( + 'src-reference/Mothbot_Process/trained_models/yolo11m_4500_imgsz1600_b1_2024-01-18.pt', + 'ami-models', + 'mothbot/detection/yolo11m_4500_imgsz1600_b1_2024-01-18.pt', +) +print('upload done') +" + +# Verify: +curl -sI "https://object-arbutus.cloud.computecanada.ca/ami-models/mothbot/detection/yolo11m_4500_imgsz1600_b1_2024-01-18.pt" | head -n 3 +# Expected: HTTP/1.1 200 OK + Content-Length around 40 MB +``` + +If `curl` returns 200, proceed to Task 1. If 403/404, stop and fix the upload before implementation continues. + +--- + +## File Structure + +Modified and new files produced by this plan: + +| File | Change | Responsibility | +|---|---|---| +| `pyproject.toml` | modify | Add `ultralytics>=8.3` to `[project].dependencies`. | +| `uv.lock` | regen | Lockfile from `uv add`. | +| `trapdata/api/api.py` | modify | Rename dict → `PIPELINE_CHOICES`; replace hardcoded `APIMothDetector` at 2 callsites with `Classifier.detector_cls`; register new pipeline slug. | +| `trapdata/api/models/classification.py` | modify | Add `detector_cls` class attr on `APIMothClassifier` base. Add `MothbotInsectOrderClassifier` subclass. | +| `trapdata/api/models/localization.py` | modify | Add `APIMothDetector_YOLO11m_Mothbot` wrapper. | +| `trapdata/api/schemas.py` | modify | Add optional `rotation: float \| None` field to `DetectionResponse`. | +| `trapdata/api/tests/test_api.py` | modify | Rename import. | +| `trapdata/api/tests/utils.py` | modify | Rename import. | +| `trapdata/antenna/worker.py` | modify | Rename import + usage. | +| `trapdata/antenna/registration.py` | modify | Rename import + usage. | +| `trapdata/cli/worker.py` | modify | Rename import + usages. | +| `trapdata/cli/base.py` | modify | Rename import. | +| `trapdata/ml/models/localization.py` | modify | Add `YoloDetection` dataclass + `MothObjectDetector_YOLO11m_Mothbot` class. | +| `trapdata/ml/models/tests/test_mothbot_yolo.py` | create | Unit test for `_corners_to_yolo_detection` helper. | +| `trapdata/api/tests/test_mothbot_pipeline.py` | create | Integration test for the new pipeline end-to-end. | + +--- + +## Task 1: Rebase worktree onto current `origin/main` + +The worktree branch is behind main (main has the uv migration and AGPL-3 license). Implementation assumes the post-uv state. + +**Files:** (none — git operation) + +- [ ] **Step 1: Fetch and inspect** + +```bash +cd /home/michael/Projects/AMI/ami-data-companion/.claude/worktrees/mothbot-pipeline +git fetch origin +git log --oneline HEAD..origin/main | head +``` + +Expected: several commits, including the uv migration merge (`029f1a8 ... feature/uv-migration` or similar) and AGPL-3 license update (`d2355c8 Update LICENSE to AGPLv3 (#137)`). + +- [ ] **Step 2: Rebase** + +```bash +git rebase origin/main +``` + +If conflicts occur, they'll almost certainly be in `poetry.lock` / `uv.lock` / `pyproject.toml` (old worktree state predates uv migration). Resolution: accept `origin/main`'s versions of `pyproject.toml` and `uv.lock`, delete `poetry.lock` if still present. The only worktree-branch content to preserve is the design doc at `docs/superpowers/specs/2026-04-14-mothbot-detection-pipeline-design.md` and this plan file. + +```bash +# If conflict in lockfile / pyproject.toml: +git checkout --theirs pyproject.toml uv.lock +git rm -f poetry.lock 2>/dev/null || true +git add pyproject.toml uv.lock +git rebase --continue +``` + +- [ ] **Step 3: Verify state** + +```bash +git log --oneline -5 +ls pyproject.toml uv.lock +grep -c "ultralytics" pyproject.toml || echo "ultralytics not yet added — expected" +head -20 pyproject.toml +``` + +Expected: recent commits include the spec doc and this plan; `uv.lock` present, `poetry.lock` absent; `pyproject.toml` uses `[project]` syntax with a `dependencies = [...]` list. + +- [ ] **Step 4: Install deps and run tests** + +```bash +uv sync +uv run pytest trapdata/api/tests/test_api.py -x 2>&1 | tail -30 +``` + +Expected: all tests pass (or the same failures as `origin/main` — record baseline). + +- [ ] **Step 5: No commit needed — rebase replays existing commits.** + +--- + +## Task 2: Rename `CLASSIFIER_CHOICES` → `PIPELINE_CHOICES` + +Pure rename, no behavior change. Done as its own commit so the diff is reviewable in isolation. + +**Files:** +- Modify: `trapdata/api/api.py` (definition at line 55, plus uses at lines 67–68, 219, 362) +- Modify: `trapdata/api/tests/test_api.py` (import line 8; uses at 65, 70, 102) +- Modify: `trapdata/api/tests/utils.py` (import line 10; use at 75) +- Modify: `trapdata/antenna/worker.py` (import line 17; use at 428) +- Modify: `trapdata/antenna/registration.py` (import line 10; use at 137) +- Modify: `trapdata/cli/worker.py` (import line 7; uses at 34, 36, 38, 43) +- Modify: `trapdata/cli/base.py` (import line 6) + +`trapdata/api/demo.py` also has a local variable named `CLASSIFIER_CHOICES` — **do not touch it**, it's an unrelated list. + +- [ ] **Step 1: Rename in `trapdata/api/api.py`** + +Use sed to rename the symbol inside this one file (demo.py excluded): + +```bash +sed -i 's/CLASSIFIER_CHOICES/PIPELINE_CHOICES/g' trapdata/api/api.py +``` + +Confirm: + +```bash +grep -n "PIPELINE_CHOICES\|CLASSIFIER_CHOICES" trapdata/api/api.py +``` + +Expected: all occurrences are now `PIPELINE_CHOICES`; zero `CLASSIFIER_CHOICES` in this file. + +- [ ] **Step 2: Rename in the six consumer files** + +```bash +sed -i 's/CLASSIFIER_CHOICES/PIPELINE_CHOICES/g' \ + trapdata/api/tests/test_api.py \ + trapdata/api/tests/utils.py \ + trapdata/antenna/worker.py \ + trapdata/antenna/registration.py \ + trapdata/cli/worker.py \ + trapdata/cli/base.py +``` + +- [ ] **Step 3: Verify no stray references remain** + +```bash +grep -rn "CLASSIFIER_CHOICES" trapdata/ +``` + +Expected: only matches are in `trapdata/api/demo.py` (the unrelated local list) — zero matches elsewhere. + +- [ ] **Step 4: Run tests** + +```bash +uv run pytest trapdata/api/tests/ -x 2>&1 | tail -20 +``` + +Expected: same pass/fail as baseline from Task 1 Step 4. No new failures. + +- [ ] **Step 5: Commit** + +```bash +git add \ + trapdata/api/api.py \ + trapdata/api/tests/test_api.py \ + trapdata/api/tests/utils.py \ + trapdata/antenna/worker.py \ + trapdata/antenna/registration.py \ + trapdata/cli/worker.py \ + trapdata/cli/base.py +git commit -m "refactor: rename CLASSIFIER_CHOICES to PIPELINE_CHOICES + +The dict maps pipeline slug to the classifier class, but it's used +as the pipeline registry. Rename for honesty. No behavior change. + +Co-Authored-By: Claude Opus 4.6 (1M context) " +``` + +--- + +## Task 3: Add `detector_cls` class attribute; replace hardcoded `APIMothDetector` + +Prepare the plumbing so different pipelines can use different detectors. Default remains `APIMothDetector`, so existing pipelines are unchanged. + +**Files:** +- Modify: `trapdata/api/models/classification.py` (add class attr on `APIMothClassifier` around line 37) +- Modify: `trapdata/api/api.py` (swap hardcoded `APIMothDetector` at lines 140 and 221) + +- [ ] **Step 1: Add import and class attribute on `APIMothClassifier`** + +In `trapdata/api/models/classification.py`, find the imports block near the top: + +```python +from ..datasets import ClassificationImageDataset +from ..schemas import ( + AlgorithmReference, + ClassificationResponse, + DetectionResponse, + SourceImage, +) +from .base import APIInferenceBaseClass +``` + +Add an import from `.localization` below `.base`: + +```python +from ..datasets import ClassificationImageDataset +from ..schemas import ( + AlgorithmReference, + ClassificationResponse, + DetectionResponse, + SourceImage, +) +from .base import APIInferenceBaseClass +from .localization import APIMothDetector +``` + +(`localization.py` doesn't import from this module, so no circular import risk.) + +Then find: + +```python +class APIMothClassifier( + APIInferenceBaseClass, + InferenceBaseClass, +): + task_type = "classification" +``` + +Replace with: + +```python +class APIMothClassifier( + APIInferenceBaseClass, + InferenceBaseClass, +): + task_type = "classification" + + # The detector class this pipeline pairs with. Subclasses override + # to pair a specific classifier with a specific detector. Default is + # the FasterRCNN 2023 detector that all existing pipelines use. + detector_cls: type[APIMothDetector] = APIMothDetector +``` + +- [ ] **Step 2: Add test that every existing pipeline inherits the default detector** + +Append to `trapdata/api/tests/test_api.py`: + +```python + def test_all_pipelines_default_to_apimothdetector(self): + """All pre-existing pipelines must keep using APIMothDetector.""" + from trapdata.api.models.localization import APIMothDetector + from trapdata.api.api import PIPELINE_CHOICES + + for slug, Classifier in PIPELINE_CHOICES.items(): + self.assertIs( + Classifier.detector_cls, + APIMothDetector, + f"{slug} should default to APIMothDetector", + ) +``` + +- [ ] **Step 3: Run the new test (expect it to pass since everything inherits)** + +```bash +uv run pytest trapdata/api/tests/test_api.py::TestInferenceAPI::test_all_pipelines_default_to_apimothdetector -v +``` + +Expected: PASS. + +- [ ] **Step 4: Swap the hardcoded detector at `api.py:221`** + +In `trapdata/api/api.py`, find: + +```python + Classifier = PIPELINE_CHOICES[str(data.pipeline)] + + detector = APIMothDetector( + source_images=source_images, + batch_size=settings.localization_batch_size, + num_workers=settings.num_workers, + # single=True if len(source_images) == 1 else False, + single=True, # @TODO solve issues with reading images in multiprocessing + ) +``` + +Replace `APIMothDetector(` with `Classifier.detector_cls(`: + +```python + Classifier = PIPELINE_CHOICES[str(data.pipeline)] + + detector = Classifier.detector_cls( + source_images=source_images, + batch_size=settings.localization_batch_size, + num_workers=settings.num_workers, + # single=True if len(source_images) == 1 else False, + single=True, # @TODO solve issues with reading images in multiprocessing + ) +``` + +- [ ] **Step 5: Swap the hardcoded detector in `make_pipeline_config_response` at `api.py:140`** + +Find: + +```python +def make_pipeline_config_response( + Classifier: type[APIMothClassifier], + slug: str, +) -> PipelineConfigResponse: + """ + Create a configuration for an entire pipeline, given a species classifier class. + """ + algorithms = [] + + detector = APIMothDetector( + source_images=[], + ) +``` + +Replace with: + +```python +def make_pipeline_config_response( + Classifier: type[APIMothClassifier], + slug: str, +) -> PipelineConfigResponse: + """ + Create a configuration for an entire pipeline, given a species classifier class. + """ + algorithms = [] + + detector = Classifier.detector_cls( + source_images=[], + ) +``` + +- [ ] **Step 6: Run full API test suite** + +```bash +uv run pytest trapdata/api/tests/test_api.py -x 2>&1 | tail -30 +``` + +Expected: all tests pass; the new `test_all_pipelines_default_to_apimothdetector` passes; no previously-passing test now fails. + +- [ ] **Step 7: Commit** + +```bash +git add trapdata/api/models/classification.py trapdata/api/api.py trapdata/api/tests/test_api.py +git commit -m "refactor: let each pipeline specify its detector via detector_cls + +Introduces a detector_cls class attribute on APIMothClassifier, +defaulting to APIMothDetector (FasterRCNN 2023). The /process and +/info handlers now read Classifier.detector_cls instead of a +hardcoded reference. No behavior change — every existing pipeline +keeps the default. + +Enables pairing a non-FasterRCNN detector with a specific classifier +in a future commit. + +Co-Authored-By: Claude Opus 4.6 (1M context) " +``` + +--- + +## Task 4: Add optional `rotation` field to `DetectionResponse` + +Forward-looking schema addition. Existing detectors leave it `None`. The YOLO detector will populate it in Task 7. No consumer reads it in this PR — the downstream classifier still crops axis-aligned. + +**Files:** +- Modify: `trapdata/api/schemas.py` (`DetectionResponse` class around line 109) + +- [ ] **Step 1: Write a failing test that `rotation` exists on `DetectionResponse`** + +Append to `trapdata/api/tests/test_api.py`: + +```python + def test_detection_response_has_optional_rotation_field(self): + """The rotation field is opt-in for detectors that produce OBB.""" + import datetime + from trapdata.api.schemas import ( + AlgorithmReference, + BoundingBox, + DetectionResponse, + ) + + # Default: rotation is None + d = DetectionResponse( + source_image_id="img1", + bbox=BoundingBox(x1=0, y1=0, x2=10, y2=10), + algorithm=AlgorithmReference(name="x", key="x"), + timestamp=datetime.datetime.now(), + ) + self.assertIsNone(d.rotation) + + # Accepts a float + d2 = DetectionResponse( + source_image_id="img1", + bbox=BoundingBox(x1=0, y1=0, x2=10, y2=10), + algorithm=AlgorithmReference(name="x", key="x"), + timestamp=datetime.datetime.now(), + rotation=-42.5, + ) + self.assertAlmostEqual(d2.rotation, -42.5) +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +uv run pytest trapdata/api/tests/test_api.py::TestInferenceAPI::test_detection_response_has_optional_rotation_field -v +``` + +Expected: FAIL — `rotation` field rejected by pydantic (default `extra="ignore"` will silently swallow it, so the `d2.rotation` check becomes `AttributeError`). Either way, it fails until the field is added. + +- [ ] **Step 3: Add the field to `DetectionResponse`** + +In `trapdata/api/schemas.py`, find: + +```python +class DetectionResponse(pydantic.BaseModel): + source_image_id: str + bbox: BoundingBox + inference_time: float | None = None + algorithm: AlgorithmReference + timestamp: datetime.datetime + crop_image_url: str | None = None + classifications: list[ClassificationResponse] = [] +``` + +Replace with: + +```python +class DetectionResponse(pydantic.BaseModel): + source_image_id: str + bbox: BoundingBox + inference_time: float | None = None + algorithm: AlgorithmReference + timestamp: datetime.datetime + crop_image_url: str | None = None + classifications: list[ClassificationResponse] = [] + rotation: float | None = pydantic.Field( + default=None, + description=( + "Rotation angle in degrees (cv2.minAreaRect convention), when " + "the detector produces oriented bounding boxes. FUTURE: " + "downstream classifiers may use this to crop a straightened " + "patch instead of the axis-aligned envelope. See " + "`docs/superpowers/specs/2026-04-14-mothbot-detection-pipeline-design.md` " + "for the proposed RotatedBoundingBox schema upgrade." + ), + ) +``` + +- [ ] **Step 4: Run tests** + +```bash +uv run pytest trapdata/api/tests/test_api.py::TestInferenceAPI::test_detection_response_has_optional_rotation_field -v +uv run pytest trapdata/api/tests/ -x 2>&1 | tail -20 +``` + +Expected: new test PASSES. All other tests still pass. + +- [ ] **Step 5: Commit** + +```bash +git add trapdata/api/schemas.py trapdata/api/tests/test_api.py +git commit -m "feat: add optional rotation field to DetectionResponse + +Forward-looking schema addition for detectors that produce oriented +bounding boxes (first consumer: Mothbot YOLO11m-OBB in a follow-up +commit). Existing detectors leave it None. The downstream classifier +still crops axis-aligned; this field is preserved so a future species +classifier can use it for rotated crops without re-running detection. + +Co-Authored-By: Claude Opus 4.6 (1M context) " +``` + +--- + +## Task 5: Add `ultralytics` dependency via `uv add` + +Dependency-only commit. Lockfile churn isolated from feature diffs. No code imports `ultralytics` yet. + +**Files:** +- Modify: `pyproject.toml` +- Modify: `uv.lock` + +- [ ] **Step 1: Add the dep** + +```bash +uv add 'ultralytics>=8.3' +``` + +- [ ] **Step 2: Verify it landed in `pyproject.toml`** + +```bash +grep -n "ultralytics" pyproject.toml +``` + +Expected: one line inside the `dependencies = [...]` array, e.g. `"ultralytics>=8.3",`. + +- [ ] **Step 3: Verify import works** + +```bash +uv run python -c "from ultralytics import YOLO; print(YOLO.__module__)" +``` + +Expected: `ultralytics.models.yolo.model` or similar — no ImportError. + +- [ ] **Step 4: Run the full test suite to confirm nothing broke from version resolution** + +```bash +uv run pytest trapdata/api/tests/ -x 2>&1 | tail -20 +``` + +Expected: all tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add pyproject.toml uv.lock +git commit -m "build: add ultralytics>=8.3 dependency + +Required for the Mothbot YOLO11m detector (follow-up commit). No +code imports it in this commit. + +Note: ultralytics is AGPL-3.0. This is not a license escalation — +the project is already AGPL-3 (PR #137). + +Co-Authored-By: Claude Opus 4.6 (1M context) " +``` + +--- + +## Task 6: Add ML-layer YOLO detector (`MothObjectDetector_YOLO11m_Mothbot`) + unit test + +TDD: write the unit test for the corner-to-detection helper first, then implement the class. + +**Files:** +- Modify: `trapdata/ml/models/localization.py` (append new class and dataclass) +- Create: `trapdata/ml/models/tests/__init__.py` (if missing) +- Create: `trapdata/ml/models/tests/test_mothbot_yolo.py` + +- [ ] **Step 1: Check if `trapdata/ml/models/tests/` exists** + +```bash +ls trapdata/ml/models/tests 2>&1 +``` + +If the directory doesn't exist, create it with an empty `__init__.py`: + +```bash +mkdir -p trapdata/ml/models/tests +touch trapdata/ml/models/tests/__init__.py +``` + +- [ ] **Step 2: Write the failing unit test** + +Create `trapdata/ml/models/tests/test_mothbot_yolo.py`: + +```python +"""Unit tests for the Mothbot YOLO detector's post-processing helpers. + +These tests stay pure-CPU and don't load any model weights — they only +exercise the coordinate math that converts YOLO's 4 rotated corner +points into the (axis-aligned-bbox + rotation + score) shape our API +consumes. The model-loading path is covered by the integration test. +""" + +import numpy as np + +from trapdata.ml.models.localization import ( + YoloDetection, + _corners_to_yolo_detection, +) + + +def test_corners_to_yolo_detection_axis_aligned_square(): + """A non-rotated square: envelope equals corners, rotation ~0 or ~90 (cv2 convention).""" + corners = np.array( + [ + [10, 10], + [20, 10], + [20, 20], + [10, 20], + ], + dtype=np.float32, + ) + det = _corners_to_yolo_detection(corners, score=0.9) + + assert isinstance(det, YoloDetection) + assert det.x1 == 10 and det.y1 == 10 + assert det.x2 == 20 and det.y2 == 20 + assert det.score == 0.9 + # cv2.minAreaRect returns angle in (-90, 0] for a non-rotated square; either + # 0 or -90 (or +90) are valid depending on corner ordering. Just assert the + # angle is a finite float in the expected range. + assert -90.0 <= det.rotation <= 90.0 + + +def test_corners_to_yolo_detection_rotated_rectangle(): + """A rectangle rotated ~45°: envelope is larger than either side, rotation non-zero.""" + # 10x4 rectangle centered at (50, 50), rotated 45°. + cx, cy = 50.0, 50.0 + half_w, half_h = 5.0, 2.0 + cos_a, sin_a = np.cos(np.pi / 4), np.sin(np.pi / 4) + + local = np.array( + [ + [-half_w, -half_h], + [+half_w, -half_h], + [+half_w, +half_h], + [-half_w, +half_h], + ], + dtype=np.float32, + ) + R = np.array([[cos_a, -sin_a], [sin_a, cos_a]], dtype=np.float32) + corners = (local @ R.T) + np.array([cx, cy], dtype=np.float32) + + det = _corners_to_yolo_detection(corners, score=0.77) + + # Envelope must contain the rotated corners + assert det.x1 <= corners[:, 0].min() + 1e-3 + assert det.y1 <= corners[:, 1].min() + 1e-3 + assert det.x2 >= corners[:, 0].max() - 1e-3 + assert det.y2 >= corners[:, 1].max() - 1e-3 + + # Envelope for a rotated thin rectangle is strictly larger than its long side + assert (det.x2 - det.x1) > 2 * half_w + + # Score passes through + assert det.score == 0.77 + + # Rotation is non-trivial for a visibly rotated rectangle + assert abs(det.rotation) > 1.0 + + +def test_yolo_detection_is_frozen_dataclass(): + """YoloDetection should be an immutable dataclass (design requirement).""" + import dataclasses + + assert dataclasses.is_dataclass(YoloDetection) + # frozen=True makes instances hashable + det = YoloDetection(x1=0, y1=0, x2=1, y2=1, rotation=0.0, score=0.5) + # Hash should not raise + hash(det) +``` + +- [ ] **Step 3: Run test to verify it fails** + +```bash +uv run pytest trapdata/ml/models/tests/test_mothbot_yolo.py -v 2>&1 | tail -20 +``` + +Expected: ImportError / ModuleNotFoundError — `YoloDetection` and `_corners_to_yolo_detection` don't exist yet. + +- [ ] **Step 4: Implement `YoloDetection` and the helper, and the detector class** + +At the top of `trapdata/ml/models/localization.py`, inside the existing imports, add: + +```python +import dataclasses +``` + +Add this `cv2` import at the top of the file (it's already used by Mothbot but may not be imported here yet — check first): + +```bash +grep -n "^import cv2\|^from cv2" trapdata/ml/models/localization.py +``` + +If no match, add `import cv2` at the top of the existing imports block. + +Append **to the end of `trapdata/ml/models/localization.py`**: + +```python +# ----------------------------------------------------------------------------- +# Mothbot YOLO11m-OBB detector +# +# Single-class ("creature") detector from Digital Naturalism Laboratories' +# Mothbot_Process project. Trained at imgsz=1600, Jan 2024. Weights are hosted +# on Arbutus alongside our other models. +# +# This implementation is an independent rewrite; Mothbot's repo is unlicensed +# (see spec). The torch 2.6 weights_only fallback below is adapted from +# Mothbot_Process/pipeline/detect.py — the pattern is standard ultralytics +# PyTorch 2.6 compat handling, not Mothbot-specific logic. +# ----------------------------------------------------------------------------- + + +@dataclasses.dataclass(frozen=True) +class YoloDetection: + """One detection from the YOLO-OBB post-processor. + + Fields: + x1, y1, x2, y2: axis-aligned envelope of the rotated bounding box + (min/max of the 4 rotated corner points). + rotation: angle in degrees, cv2.minAreaRect convention. + score: detection confidence, in [0, 1]. + """ + + x1: float + y1: float + x2: float + y2: float + rotation: float + score: float + + +def _corners_to_yolo_detection(corners: np.ndarray, score: float) -> YoloDetection: + """Convert 4 rotated corner points + score into a YoloDetection. + + Args: + corners: shape (4, 2), xy coordinates of the OBB corners. + score: detection confidence. + + Returns: + A YoloDetection with: + - (x1, y1, x2, y2): min/max envelope of the 4 corners (axis-aligned). + - rotation: angle from cv2.minAreaRect (same convention Mothbot uses). + """ + pts = np.asarray(corners, dtype=np.float32).reshape(-1, 2) + x1, y1 = float(pts[:, 0].min()), float(pts[:, 1].min()) + x2, y2 = float(pts[:, 0].max()), float(pts[:, 1].max()) + rect = cv2.minAreaRect(pts.astype(np.int32)) + angle = float(rect[2]) + return YoloDetection(x1=x1, y1=y1, x2=x2, y2=y2, rotation=angle, score=float(score)) + + +def _load_ultralytics_yolo(weights_path: str): + """Load an ultralytics YOLO model with a PyTorch 2.6 weights_only fallback. + + Newer PyTorch defaults to torch.load(..., weights_only=True), which can + refuse to load Ultralytics checkpoints that embed custom model classes. + For local, trusted checkpoints we retry with weights_only=False. + + Adapted from Mothbot_Process/pipeline/detect.py (unlicensed repo; pattern + is standard ultralytics PyTorch 2.6 compat handling). + """ + # Import lazily so the ML module doesn't pay the ultralytics import cost + # for users who never touch this detector. + import os + + import torch as _torch + from ultralytics import YOLO + + try: + return YOLO(str(weights_path)) + except Exception as err: + if "Weights only load failed" not in str(err): + raise + + logger.info( + "Retrying YOLO load with torch.load(weights_only=False) compatibility " + "(trusted local checkpoint)" + ) + original_load = _torch.load + original_force_wo = os.environ.get("TORCH_FORCE_WEIGHTS_ONLY_LOAD") + original_force_no_wo = os.environ.get("TORCH_FORCE_NO_WEIGHTS_ONLY_LOAD") + + def _patched_load(*args, **kwargs): + kwargs["weights_only"] = False + return original_load(*args, **kwargs) + + _torch.load = _patched_load + try: + os.environ["TORCH_FORCE_WEIGHTS_ONLY_LOAD"] = "0" + os.environ["TORCH_FORCE_NO_WEIGHTS_ONLY_LOAD"] = "1" + return YOLO(str(weights_path)) + finally: + _torch.load = original_load + if original_force_wo is None: + os.environ.pop("TORCH_FORCE_WEIGHTS_ONLY_LOAD", None) + else: + os.environ["TORCH_FORCE_WEIGHTS_ONLY_LOAD"] = original_force_wo + if original_force_no_wo is None: + os.environ.pop("TORCH_FORCE_NO_WEIGHTS_ONLY_LOAD", None) + else: + os.environ["TORCH_FORCE_NO_WEIGHTS_ONLY_LOAD"] = original_force_no_wo + + +class MothObjectDetector_YOLO11m_Mothbot(ObjectDetector): + name = "Mothbot YOLO11m Creature Detector" + weights_path = ( + "https://object-arbutus.cloud.computecanada.ca/ami-models/" + "mothbot/detection/yolo11m_4500_imgsz1600_b1_2024-01-18.pt" + ) + description = ( + "Single-class 'creature' detector from Digital Naturalism " + "Laboratories' Mothbot project. YOLO11m-OBB, trained at " + "imgsz=1600, Jan 2024." + ) + # Overrides the base: we set the category map directly instead of + # hosting a one-entry labels.json on the object store. + category_map = {0: "creature"} + imgsz = 1600 + bbox_score_threshold = 0.25 + box_detections_per_img = 500 + + def get_transforms(self): + # ultralytics handles letterboxing / normalization internally; just + # pass the PIL image through unchanged. + return lambda pil_image: pil_image + + def get_model(self): + logger.debug(f"Loading YOLO weights: {self.weights}") + model = _load_ultralytics_yolo(self.weights) + # ultralytics manages its own device placement via the device kwarg + # passed to .predict(), so we don't .to(self.device) here. + return model + + def get_dataloader(self): + """PIL images can't be stacked by default_collate, so we collate as + lists and let predict_batch hand a list of PIL images to ultralytics. + """ + logger.info( + f"Preparing {self.name} inference dataloader " + f"(batch_size={self.batch_size}, single={self.single})" + ) + + def collate_as_lists(batch): + ids = [b[0] for b in batch] + imgs = [b[1] for b in batch] + return ids, imgs + + dataloader_args = { + "num_workers": 0 if self.single else self.num_workers, + "persistent_workers": False if self.single else True, + "shuffle": False, + "pin_memory": False, + "batch_size": self.batch_size, + "collate_fn": collate_as_lists, + } + self.dataloader = torch.utils.data.DataLoader(self.dataset, **dataloader_args) + return self.dataloader + + def predict_batch(self, batch): + """batch is a list[PIL.Image]. Returns a list of ultralytics Results.""" + if not isinstance(batch, list): + raise TypeError( + f"{self.name} expects a list of PIL images from the collate fn; " + f"got {type(batch)}" + ) + return self.model.predict( + batch, + imgsz=self.imgsz, + conf=self.bbox_score_threshold, + max_det=self.box_detections_per_img, + device=self.device, + verbose=False, + ) + + def post_process_single(self, result) -> list[YoloDetection]: + """Flatten one ultralytics Result into a list of detection records. + + Why the OBB → axis-aligned envelope: + YOLO11m-OBB outputs 4 rotated corner points per detection. Our + DetectionResponse schema carries a single axis-aligned bbox, and + the downstream InsectOrderClassifier reads an axis-aligned crop. + We therefore take the min/max envelope of the 4 corners as the + bbox. The rotation angle (cv2.minAreaRect convention, same as + Mothbot) is preserved separately so a future species classifier + can reuse Mothbot's rotated crop_rect() without re-running + detection. + + Confidence filtering already happened inside model.predict(conf=...), + so every record here is above bbox_score_threshold. + """ + detections: list[YoloDetection] = [] + if result.obb is None: + return detections + corners_batch = result.obb.xyxyxyxy.cpu().numpy() # (N, 4, 2) + scores = result.obb.conf.cpu().numpy() # (N,) + for i in range(len(corners_batch)): + detections.append( + _corners_to_yolo_detection(corners_batch[i], float(scores[i])) + ) + return detections + + def save_results(self, item_ids, batch_output, *args, **kwargs): + """The ML-layer base class expects a save method. The API wrapper + overrides this, so the DB path is never hit when used via the API. + Provide a no-op that logs, for symmetry with the FasterRCNN class's + behavior. + """ + logger.info( + f"{self.name} ML-layer save_results called with {len(item_ids)} items " + "(no-op; API wrapper handles persistence)" + ) +``` + +- [ ] **Step 5: Run the unit test** + +```bash +uv run pytest trapdata/ml/models/tests/test_mothbot_yolo.py -v +``` + +Expected: all three tests PASS. + +- [ ] **Step 6: Confirm the module imports cleanly** + +```bash +uv run python -c "from trapdata.ml.models.localization import MothObjectDetector_YOLO11m_Mothbot, YoloDetection; print('ok')" +``` + +Expected: prints `ok`. + +- [ ] **Step 7: Commit** + +```bash +git add trapdata/ml/models/localization.py trapdata/ml/models/tests/__init__.py trapdata/ml/models/tests/test_mothbot_yolo.py +git commit -m "feat: add Mothbot YOLO11m-OBB detector (ML layer) + +Implements MothObjectDetector_YOLO11m_Mothbot, a single-class +('creature') insect detector trained by Digital Naturalism +Laboratories. Weights hosted on Arbutus and lazily downloaded +via the existing InferenceBaseClass machinery. + +Adds a YoloDetection dataclass and a _corners_to_yolo_detection +helper that converts OBB corners into an axis-aligned envelope + +rotation angle, with unit tests on the coordinate math. + +The torch 2.6 weights_only fallback is adapted from +Mothbot_Process/pipeline/detect.py (unlicensed repo; pattern is +standard ultralytics PyTorch 2.6 compat handling). + +Co-Authored-By: Claude Opus 4.6 (1M context) " +``` + +--- + +## Task 7: Add API-layer YOLO detector wrapper (`APIMothDetector_YOLO11m_Mothbot`) + +Wraps the ML class for the API request path: pulls `SourceImage` objects, builds `DetectionResponse`s (with populated `rotation`), skips the DB. + +**Files:** +- Modify: `trapdata/api/models/localization.py` (append) + +- [ ] **Step 1: Append the wrapper class** + +At the **end of `trapdata/api/models/localization.py`**, add: + +```python +from trapdata.ml.models.localization import ( + MothObjectDetector_YOLO11m_Mothbot, + YoloDetection, +) + + +class APIMothDetector_YOLO11m_Mothbot( + APIInferenceBaseClass, MothObjectDetector_YOLO11m_Mothbot +): + task_type = "localization" + + def __init__(self, source_images: typing.Iterable[SourceImage], *args, **kwargs): + self.source_images = source_images + self.results: list[DetectionResponse] = [] + super().__init__(*args, **kwargs) + + def reset(self, source_images: typing.Iterable[SourceImage]): + self.source_images = source_images + self.results = [] + + def get_dataset(self): + return LocalizationImageDataset( + self.source_images, self.get_transforms(), batch_size=self.batch_size + ) + + def get_source_image(self, source_image_id: int) -> SourceImage: + for source_image in self.source_images: + if source_image.id == source_image_id: + return source_image + raise ValueError(f"Source image with id {source_image_id} not found") + + def save_results(self, item_ids, batch_output, seconds_per_item, *args, **kwargs): + """batch_output is a list (one per image) of list[YoloDetection].""" + detections: list[DetectionResponse] = [] + for image_id, yolo_dets in zip(item_ids, batch_output): + for y in yolo_dets: + detections.append( + DetectionResponse( + source_image_id=image_id, + bbox=BoundingBox(x1=y.x1, y1=y.y1, x2=y.x2, y2=y.y2), + rotation=y.rotation, + inference_time=seconds_per_item, + algorithm=AlgorithmReference( + name=self.name, key=self.get_key() + ), + timestamp=datetime.datetime.now(), + crop_image_url=None, + ) + ) + self.results += detections + + def run(self) -> list[DetectionResponse]: + super().run() + return self.results +``` + +- [ ] **Step 2: Add a smoke-test that the class instantiates with no source images** + +Append to `trapdata/api/tests/test_api.py`: + +```python + def test_yolo_api_detector_instantiates(self): + """The new YOLO detector wrapper should construct with no source images + (matches the pattern the /info handler uses to read algorithm metadata). + The test exercises weight download + model load — it will be slow on + first run but cached thereafter. + """ + from trapdata.api.models.localization import ( + APIMothDetector_YOLO11m_Mothbot, + ) + + detector = APIMothDetector_YOLO11m_Mothbot(source_images=[]) + self.assertEqual(detector.name, "Mothbot YOLO11m Creature Detector") + self.assertEqual(detector.category_map, {0: "creature"}) + self.assertEqual(detector.imgsz, 1600) +``` + +- [ ] **Step 3: Run tests** + +```bash +uv run pytest trapdata/api/tests/test_api.py::TestInferenceAPI::test_yolo_api_detector_instantiates -v 2>&1 | tail -30 +``` + +Expected: PASS. First run downloads ~40 MB weights from Arbutus (may take 10–30 s depending on connection). + +If the test fails with a download error, verify the operator upload step completed: + +```bash +curl -sI "https://object-arbutus.cloud.computecanada.ca/ami-models/mothbot/detection/yolo11m_4500_imgsz1600_b1_2024-01-18.pt" | head -n 3 +``` + +- [ ] **Step 4: Full API test suite** + +```bash +uv run pytest trapdata/api/tests/ -x 2>&1 | tail -20 +``` + +Expected: all tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add trapdata/api/models/localization.py trapdata/api/tests/test_api.py +git commit -m "feat: add API wrapper for Mothbot YOLO11m detector + +Wraps MothObjectDetector_YOLO11m_Mothbot for the /process endpoint: +consumes SourceImage objects, builds DetectionResponses with the new +rotation field populated from the YOLO-OBB angle. No pipeline uses +this detector yet — registration follows in the next commit. + +Co-Authored-By: Claude Opus 4.6 (1M context) " +``` + +--- + +## Task 8: Register `mothbot_insect_orders_2025` pipeline + +Pair the new detector with the existing `InsectOrderClassifier2025` as a new pipeline choice. + +**Files:** +- Modify: `trapdata/api/models/classification.py` +- Modify: `trapdata/api/api.py` + +- [ ] **Step 1: Add `MothbotInsectOrderClassifier` in `classification.py`** + +In `trapdata/api/models/classification.py`, extend the `.localization` import added in Task 3 to also include the YOLO detector. Find: + +```python +from .localization import APIMothDetector +``` + +Replace with: + +```python +from .localization import APIMothDetector, APIMothDetector_YOLO11m_Mothbot +``` + +Right after the **existing** `InsectOrderClassifier` definition at the bottom of the file, add: + +```python +class MothbotInsectOrderClassifier(InsectOrderClassifier): + """Pair the Mothbot YOLO11m detector with our existing ConvNeXt order + classifier. Overrides the default detector_cls inherited from + APIMothClassifier. + """ + + detector_cls = APIMothDetector_YOLO11m_Mothbot +``` + +- [ ] **Step 2: Register in `PIPELINE_CHOICES`** + +In `trapdata/api/api.py`, find: + +```python +from .models.classification import ( + APIMothClassifier, + InsectOrderClassifier, + MothClassifierBinary, + ... +) +``` + +Add `MothbotInsectOrderClassifier` to the imports. + +Then find: + +```python +PIPELINE_CHOICES = { + "panama_moths_2023": MothClassifierPanama, + ... + "moth_binary": MothClassifierBinary, + "insect_orders_2025": InsectOrderClassifier, +} +``` + +Add the new entry: + +```python +PIPELINE_CHOICES = { + "panama_moths_2023": MothClassifierPanama, + ... + "moth_binary": MothClassifierBinary, + "insect_orders_2025": InsectOrderClassifier, + "mothbot_insect_orders_2025": MothbotInsectOrderClassifier, +} +``` + +Update `should_filter_detections` at `api.py:76` so the new classifier also skips the binary filter (it inherits `InsectOrderClassifier`, so the `isinstance` check already covers it — but the current implementation uses `in [MothClassifierBinary, InsectOrderClassifier]` which does NOT cover subclasses. Change to `issubclass`). + +Find: + +```python +def should_filter_detections(Classifier: type[APIMothClassifier]) -> bool: + if Classifier in [MothClassifierBinary, InsectOrderClassifier]: + return False + else: + return True +``` + +Replace with: + +```python +def should_filter_detections(Classifier: type[APIMothClassifier]) -> bool: + # Classifiers that skip the binary moth/non-moth prefilter: the binary + # classifier itself (there's nothing downstream to filter for), and any + # order-level classifier (it already distinguishes non-moth insects, + # so a binary prefilter would discard signal). + if issubclass(Classifier, (MothClassifierBinary, InsectOrderClassifier)): + return False + return True +``` + +- [ ] **Step 3: Update the existing "all pipelines default to APIMothDetector" test** + +The test added in Task 3 now needs to exempt `mothbot_insect_orders_2025` from the default-detector assertion. In `trapdata/api/tests/test_api.py`, find: + +```python + def test_all_pipelines_default_to_apimothdetector(self): + """All pre-existing pipelines must keep using APIMothDetector.""" + from trapdata.api.models.localization import APIMothDetector + from trapdata.api.api import PIPELINE_CHOICES + + for slug, Classifier in PIPELINE_CHOICES.items(): + self.assertIs( + Classifier.detector_cls, + APIMothDetector, + f"{slug} should default to APIMothDetector", + ) +``` + +Replace with: + +```python + def test_existing_pipelines_default_to_apimothdetector(self): + """Pre-existing pipelines must keep using APIMothDetector. + + New pipelines introduced with their own detector are exempt. + """ + from trapdata.api.models.localization import APIMothDetector + from trapdata.api.api import PIPELINE_CHOICES + + exempt = {"mothbot_insect_orders_2025"} + for slug, Classifier in PIPELINE_CHOICES.items(): + if slug in exempt: + continue + self.assertIs( + Classifier.detector_cls, + APIMothDetector, + f"{slug} should default to APIMothDetector", + ) +``` + +- [ ] **Step 4: Add a test that the new pipeline is registered with the YOLO detector** + +Append to `trapdata/api/tests/test_api.py`: + +```python + def test_mothbot_pipeline_uses_yolo_detector(self): + from trapdata.api.api import PIPELINE_CHOICES + from trapdata.api.models.localization import APIMothDetector_YOLO11m_Mothbot + + assert "mothbot_insect_orders_2025" in PIPELINE_CHOICES + Classifier = PIPELINE_CHOICES["mothbot_insect_orders_2025"] + self.assertIs(Classifier.detector_cls, APIMothDetector_YOLO11m_Mothbot) + + def test_mothbot_pipeline_skips_binary_filter(self): + from trapdata.api.api import PIPELINE_CHOICES, should_filter_detections + + Classifier = PIPELINE_CHOICES["mothbot_insect_orders_2025"] + self.assertFalse(should_filter_detections(Classifier)) +``` + +- [ ] **Step 5: Run tests** + +```bash +uv run pytest trapdata/api/tests/test_api.py -v -k "mothbot or existing_pipelines_default" 2>&1 | tail -30 +``` + +Expected: all three new/updated tests PASS. + +- [ ] **Step 6: Run full API test suite** + +```bash +uv run pytest trapdata/api/tests/ -x 2>&1 | tail -20 +``` + +Expected: all tests pass. + +- [ ] **Step 7: Commit** + +```bash +git add trapdata/api/models/classification.py trapdata/api/api.py trapdata/api/tests/test_api.py +git commit -m "feat: register mothbot_insect_orders_2025 pipeline + +Pairs the Mothbot YOLO11m detector with the existing +InsectOrderClassifier2025 (ConvNeXt-T, 16 insect orders). Binary +prefilter is skipped — same policy as the existing +insect_orders_2025 pipeline, since the order classifier already +distinguishes non-moth insects. + +Also tightens should_filter_detections() to use issubclass() so +subclasses of the exempt classifier set inherit the policy. + +Co-Authored-By: Claude Opus 4.6 (1M context) " +``` + +--- + +## Task 9: End-to-end integration test for the new pipeline + +Runs the new pipeline against one test image through the FastAPI test client. First run downloads the YOLO weights and the order classifier weights (~100 MB total) — cached thereafter. + +**Files:** +- Create: `trapdata/api/tests/test_mothbot_pipeline.py` + +- [ ] **Step 1: Write the integration test** + +Create `trapdata/api/tests/test_mothbot_pipeline.py`: + +```python +"""Integration test for the Mothbot YOLO + Insect Order classifier pipeline. + +This test runs the full /process handler end-to-end for the new +`mothbot_insect_orders_2025` pipeline slug. It will download the YOLO +weights (~40 MB) and the ConvNeXt order classifier weights (~55 MB) +from Arbutus on first run, then cache them. + +The test is intentionally loose about contents — it asserts that the +pipeline runs, returns a well-formed response, and that the YOLO +detector populates the new `rotation` field. Accuracy is out of scope +for this suite. +""" + +import logging +import pathlib +from unittest import TestCase + +from fastapi.testclient import TestClient + +from trapdata.api.api import ( + PipelineChoice, + PipelineRequest, + PipelineResponse, + app, +) +from trapdata.api.tests.image_server import StaticFileTestServer +from trapdata.api.tests.utils import get_test_images +from trapdata.tests import TEST_IMAGES_BASE_PATH + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +class TestMothbotPipeline(TestCase): + @classmethod + def setUpClass(cls): + cls.test_images_dir = pathlib.Path(TEST_IMAGES_BASE_PATH) + if not cls.test_images_dir.exists(): + raise FileNotFoundError( + f"Test images directory not found: {cls.test_images_dir}" + ) + cls.file_server = StaticFileTestServer(cls.test_images_dir) + cls.client = TestClient(app) + + @classmethod + def tearDownClass(cls): + if hasattr(cls, "file_server"): + cls.file_server.stop() + + def test_mothbot_pipeline_end_to_end(self): + """Send one vermont test image through the new pipeline.""" + test_images = get_test_images( + self.file_server, self.test_images_dir, subdir="vermont", num=1 + ) + assert test_images, "No test images found" + + pipeline_request = PipelineRequest( + pipeline=PipelineChoice["mothbot_insect_orders_2025"], + source_images=test_images, + ) + with self.file_server: + response = self.client.post( + "/process", json=pipeline_request.model_dump() + ) + self.assertEqual( + response.status_code, 200, f"Unexpected status: {response.text[:500]}" + ) + + result = PipelineResponse(**response.json()) + self.assertTrue(result.detections, "pipeline returned no detections") + + # At least one detection should carry a rotation (YOLO-OBB populates it) + rotations = [d.rotation for d in result.detections] + self.assertTrue( + any(r is not None for r in rotations), + "YOLO detector should populate the rotation field on at least one " + "detection", + ) + + # Each detection should have an order classification from the terminal + # classifier. (Binary prefilter is skipped for this pipeline.) + for detection in result.detections: + terminal = [c for c in detection.classifications if c.terminal] + self.assertTrue( + terminal, + f"detection {detection.bbox} has no terminal classification", + ) + self.assertEqual( + terminal[0].algorithm.key, + "insect-order-classifier", + f"expected order classifier, got {terminal[0].algorithm.key}", + ) +``` + +**Note on the expected algorithm key:** the test asserts `terminal[0].algorithm.key == "insect-order-classifier"`. This comes from `slugify(InsectOrderClassifier2025.name)` → `slugify("Insect Order Classifier")`. Before running, verify the slug is correct by inspecting the key locally: + +```bash +uv run python -c " +from trapdata.common.utils import slugify +from trapdata.ml.models.classification import InsectOrderClassifier2025 +print(slugify(InsectOrderClassifier2025.name)) +" +``` + +If the output is something other than `insect-order-classifier`, update the assertion to match. + +- [ ] **Step 2: Run the integration test** + +```bash +uv run pytest trapdata/api/tests/test_mothbot_pipeline.py -v -s 2>&1 | tail -40 +``` + +Expected: PASS. First run may take 30–120 s while weights download; subsequent runs should finish in 10–30 s on CPU. + +If the test fails: +- Empty `detections` → the test image may be a trap with nothing on it; try swapping `num=1` for `num=2` or pick a different `subdir`. Check with `ls trapdata/tests/images/`. +- `ImportError` → confirm Task 5 installed ultralytics: `uv run python -c "import ultralytics"`. +- Download error → verify weights are on Arbutus (see "Before starting" section). + +- [ ] **Step 3: Full test suite green check** + +```bash +uv run pytest -x 2>&1 | tail -30 +``` + +Expected: all tests pass. + +- [ ] **Step 4: Commit** + +```bash +git add trapdata/api/tests/test_mothbot_pipeline.py +git commit -m "test: end-to-end integration test for mothbot pipeline + +Sends one test image through the /process endpoint with the +mothbot_insect_orders_2025 slug, asserts detections are returned, +at least one has a populated rotation field, and each has an +order-level terminal classification. + +Co-Authored-By: Claude Opus 4.6 (1M context) " +``` + +--- + +## PR checklist (for the author, when opening the PR) + +- [ ] Push branch: `git push -u origin worktree-mothbot-pipeline` +- [ ] Open PR with title: `feat: add Mothbot YOLO11m detection pipeline` +- [ ] PR body covers: + - What: new `mothbot_insect_orders_2025` pipeline slug + - Why: gives users a Mothbot-style detector paired with our existing order classifier + - `CLASSIFIER_CHOICES` → `PIPELINE_CHOICES` rename (separate commit, reviewable alone) + - `detector_cls` attribute on `APIMothClassifier` enables per-pipeline detectors + - New optional `rotation` field on `DetectionResponse` — forward-looking, unused by consumers in this PR; proposed full `RotatedBoundingBox` upgrade discussed in the linked spec + - Single-class YOLO (confirmed via checkpoint inspection: `names={0: 'creature'}`) — taxonomic labels come from the existing ConvNeXt classifier, not the detector + - Dependency: `ultralytics>=8.3` (AGPL-3); project is already AGPL-3 so no license escalation; YOLO weights checkpoint carries embedded AGPL-3 metadata + - Mothbot repo has no explicit license — we re-implement rather than verbatim-port; one adapted snippet (torch 2.6 weights_only fallback) attributed inline + - Test plan: unit test on OBB → envelope math; integration test on full pipeline + - Operator note: YOLO weights uploaded to `ami-models/mothbot/detection/` on Arbutus before merge +- [ ] Link to spec: `docs/superpowers/specs/2026-04-14-mothbot-detection-pipeline-design.md` + +--- + +## Known follow-ups (out of scope for this PR) + +- Port / reimplement Mothbot's pybioclip classifier as a second new pipeline. +- Full `RotatedBoundingBox` schema + rotated crop support in `ClassificationImageDataset`, letting a species classifier consume tighter rotated crops. +- Accuracy/latency evaluation: YOLO vs. FasterRCNN 2023 on a shared test set. diff --git a/docs/superpowers/specs/2026-04-14-mothbot-detection-pipeline-design.md b/docs/superpowers/specs/2026-04-14-mothbot-detection-pipeline-design.md new file mode 100644 index 00000000..9196cb25 --- /dev/null +++ b/docs/superpowers/specs/2026-04-14-mothbot-detection-pipeline-design.md @@ -0,0 +1,276 @@ +# Mothbot YOLO detection pipeline — design + +**Date:** 2026-04-14 +**Status:** Approved (awaiting implementation plan) +**Scope:** Add a new API pipeline `mothbot_insect_orders_2025` that pairs the Mothbot YOLO11m-OBB detector with the existing `InsectOrderClassifier2025`. + +## Context + +[Mothbot_Process](https://github.com/Digital-Naturalism-Laboratories/Mothbot_Process) (Digital Naturalism Labs) ships a YOLO11m-OBB insect detector trained on moth-trap imagery. Reference checkout at `src-reference/Mothbot_Process/`. Its detection stage (`pipeline/detect.py`) feeds oriented-box crops into a pybioclip+GBIF classifier (`pipeline/identify.py`) — Mothbot's classifier is **out of scope** for this task and will be considered in a follow-up. + +**What we're doing:** exposing their detector as a new pipeline choice on our API, paired with our existing ConvNeXt-T order classifier. + +**What we're not doing:** + +- No change to the legacy CLI/desktop pipeline (`trapdata/ml/pipeline.py`). The new ML-layer detector class will still register for it automatically via `trapdata/ml/models/__init__.py:25`, so it's available there for free if someone wants it later. +- No pybioclip integration. +- No full rotated-bbox schema; see "Rotation field" below. + +## Non-obvious facts verified up front + +- **The YOLO model is single-class.** Inspection of `yolo11m_4500_imgsz1600_b1_2024-01-18.pt` gives `nc=1`, `names={0: 'creature'}`. Mothbot's `detect.py` hardcodes `"label": "creature"` and never reads `obb.cls`. Any taxonomic labeling must come from the classifier downstream. If a future Mothbot-trained YOLO ships with multiple classes, the detector wrapper would need to surface `obb.cls` and we'd revisit the detector→classifier contract. +- **Current `BoundingBox` schema is axis-aligned.** `trapdata/api/schemas.py:12`. The Mothbot detector produces oriented boxes. We handle this by taking the axis-aligned envelope of the 4 rotated corners and preserving the rotation angle in a new optional `rotation` field — see "Rotation field" below. +- **`origin/main` has already merged the uv migration (PR #115) and is AGPL-3 licensed (PR #137).** The worktree branch `worktree-mothbot-pipeline` is behind main — step 0 of the rollout is a rebase. + +## Design + +### Architecture choice: `detector_cls` attribute on the classifier + +Today, `CLASSIFIER_CHOICES` in `trapdata/api/api.py:55` maps pipeline slug → `APIMothClassifier` subclass, and the detector is hardcoded to `APIMothDetector` at `api.py:221`. All 10 existing pipelines share the same detector. + +**Approaches considered:** + +- **1. Tuple registry** — `slug → (DetectorClass, ClassifierClass)`. Touches every existing pipeline entry. +- **2. `PipelineConfig` dataclass** — cleanest model, biggest blast radius. Overkill for one new pipeline. +- **3. `detector_cls` class attribute on the classifier** — smallest diff; only pipelines that differ from the default need to set it. **Chosen.** + +The "detector on the classifier" coupling is a minor abuse (the detector is logically a sibling of the classifier, not a member), but the codebase already hangs pipeline-level concerns on classifiers (`positive_binary_label`, `get_key()`, binary-filter routing), so it's in character. If a future pipeline needs to vary more axes than just the detector, that's the moment to upgrade to Approach 2. + +### Rename `CLASSIFIER_CHOICES` → `PIPELINE_CHOICES` + +Honest naming — the dict maps to pipelines, not classifiers. Seven files reference the current name: + +- `trapdata/api/api.py` (definition + 3 uses) +- `trapdata/api/tests/test_api.py` (4 uses) +- `trapdata/api/tests/utils.py` (2 uses) +- `trapdata/antenna/worker.py` (2 uses) +- `trapdata/antenna/registration.py` (2 uses) +- `trapdata/cli/worker.py` (4 uses) +- `trapdata/cli/base.py` (1 use) + +`trapdata/api/demo.py` has an unrelated local list also named `CLASSIFIER_CHOICES` — untouched. + +Done as its own commit so the rename diff is reviewable separately. + +### New detector classes + +Two-layer split mirrors the FasterRCNN family's ML/API split. + +**ML layer — `trapdata/ml/models/localization.py`:** + +```python +@dataclass(frozen=True) +class YoloDetection: + x1: float; y1: float; x2: float; y2: float + rotation: float # degrees, cv2.minAreaRect convention + score: float + + +class MothObjectDetector_YOLO11m_Mothbot(ObjectDetector): + name = "Mothbot YOLO11m Creature Detector" + description = ( + "Single-class 'creature' detector from Digital Naturalism " + "Laboratories' Mothbot project. YOLO11m-OBB, trained at " + "imgsz=1600, Jan 2024." + ) + weights_path = ( + "https://object-arbutus.cloud.computecanada.ca/ami-models/" + "mothbot/detection/yolo11m_4500_imgsz1600_b1_2024-01-18.pt" + ) + category_map = {0: "creature"} # overrides the base; no labels.json + imgsz = 1600 + bbox_score_threshold = 0.25 # matches naming in FasterRCNN family + box_detections_per_img = 500 # mirrors FasterRCNN 2023 +``` + +**Key integration notes:** + +- **Weights loading**: `InferenceBaseClass.get_weights()` already pulls from the URL via `get_or_download_file()`. `get_model()` wraps the local path in `ultralytics.YOLO(...)` with a **PyTorch 2.6 `weights_only` fallback** lifted verbatim from Mothbot's `load_yolo_model()` (`src-reference/Mothbot_Process/pipeline/detect.py:44-97`) — handles newer PyTorch refusing to load ultralytics checkpoints that embed custom classes. +- **Transforms**: `get_transforms()` returns `lambda pil: pil` (identity). YOLO does its own letterboxing/normalization. +- **Dataloader**: `get_dataloader()` overrides the base to set `collate_fn=lambda batch: (list(b[0] for b in batch), list(b[1] for b in batch))` — default collate can't stack PIL images (and source images differ in size anyway). +- **Batching**: `single=True` matches the existing API pipeline's `APIMothDetector` usage at `api.py:227`. +- **`predict_batch(batch)`** with `batch: list[PIL.Image]`: `self.model.predict(batch, imgsz=self.imgsz, conf=self.bbox_score_threshold, max_det=self.box_detections_per_img, device=self.device, verbose=False)`. +- **Confidence filtering** happens inside `model.predict(conf=...)` — unlike FasterRCNN where we filter in `post_process_single`. Asymmetric with existing code, but matches how ultralytics is meant to be used. + +**Post-processing:** + +```python +def post_process_single(self, result) -> list[YoloDetection]: + """ + Flatten one ultralytics Result (an image's worth of OBB predictions) + into a list of detection records the API layer can turn into + DetectionResponse objects. + + Why the OBB → axis-aligned envelope: + YOLO11m-OBB outputs 4 rotated corner points per detection. Our + DetectionResponse schema carries a single axis-aligned bbox, and the + downstream InsectOrderClassifier reads an axis-aligned crop. We + therefore take the min/max envelope of the 4 corners as the bbox. + The rotation angle (from cv2.minAreaRect, same convention Mothbot + uses) is preserved separately so a future species classifier can + reuse Mothbot's rotated crop_rect() without re-running detection. + + Confidence filtering already happened inside model.predict(conf=...), + so every record here is above bbox_score_threshold. + """ +``` + +Implementation: + +```python +pts = obb.xyxyxyxy[i].cpu().numpy().reshape(-1, 2) # (4, 2) +score = float(obb.conf[i]) +x1, y1 = pts[:, 0].min(), pts[:, 1].min() +x2, y2 = pts[:, 0].max(), pts[:, 1].max() +rect = cv2.minAreaRect(pts.astype(int)) # same as Mothbot +angle = rect[2] # degrees +``` + +**What this detector deliberately does NOT do** (diverging from Mothbot's `detect.py`): + +- No thumbnail/patch files on disk (`generateThumbnailPatches`) — API returns crops in-band via existing paths. +- No JSON sidecar output (`_botdetection.json`) — API returns results in the response. +- No human-detection-file override logic. + +**API layer — `trapdata/api/models/localization.py`:** + +```python +class APIMothDetector_YOLO11m_Mothbot( + APIInferenceBaseClass, MothObjectDetector_YOLO11m_Mothbot +): + task_type = "localization" + # __init__, reset, get_dataset identical in shape to APIMothDetector + def save_results(self, item_ids, batch_output, seconds_per_item, *args, **kwargs): + # Unpack YoloDetection → DetectionResponse with rotation+score populated. +``` + +### `detector_cls` wiring + +**`trapdata/api/models/classification.py`:** + +```python +class APIMothClassifier(APIInferenceBaseClass, InferenceBaseClass): + task_type = "classification" + detector_cls: type["APIMothDetector"] = APIMothDetector # default +``` + +**`trapdata/api/api.py`:** + +- `api.py:221` — replace `APIMothDetector(...)` with `Classifier.detector_cls(...)`. +- `api.py:140` (`make_pipeline_config_response`) — same substitution. + +### New classifier registration + +**`trapdata/api/models/classification.py`:** + +```python +class MothbotInsectOrderClassifier(InsectOrderClassifier): + detector_cls = APIMothDetector_YOLO11m_Mothbot +``` + +**`trapdata/api/api.py`:** add `"mothbot_insect_orders_2025": MothbotInsectOrderClassifier` to `PIPELINE_CHOICES`. + +**Binary filter**: skipped. `should_filter_detections()` at `api.py:76` already returns `False` for `InsectOrderClassifier` subclasses — `MothbotInsectOrderClassifier` inherits that behavior. Same as the existing `insect_orders_2025` pipeline. Rationale: the order classifier itself distinguishes non-moth insects; a binary prefilter would discard signal. + +### Schema: `rotation` field on `DetectionResponse` + +**`trapdata/api/schemas.py`:** + +```python +class DetectionResponse(pydantic.BaseModel): + # ... existing fields ... + rotation: float | None = pydantic.Field( + default=None, + description=( + "Rotation angle in degrees (cv2.minAreaRect convention), when " + "the detector produces oriented bounding boxes. FUTURE: " + "downstream classifiers may use this to crop a straightened " + "patch instead of the axis-aligned envelope. See PR discussion " + "for the proposed RotatedBoundingBox schema upgrade." + ), + ) +``` + +Backwards-compatible. Existing FasterRCNN detectors leave it `None`. The Mothbot detector populates it. **This PR does not use the rotation downstream** — the order classifier still crops axis-aligned. The field is there for a future species classifier to use. The description text and PR body will both explicitly call this out as a forward-looking proposal. + +### Dependency + +`uv add ultralytics` — `pyproject.toml` gets `"ultralytics>=8.3"` in the `[project].dependencies` list (PEP 621), `uv.lock` updates. Separate commit so lockfile churn doesn't muddy the feature diffs. + +### Licensing + +| Component | License | Notes | +|---|---|---| +| `yolo11m_4500_imgsz1600_b1_2024-01-18.pt` weights | AGPL-3.0 | Tagged in checkpoint metadata: `license: AGPL-3.0 (https://ultralytics.com/license)`. | +| `ultralytics` library | AGPL-3.0 | Upstream. | +| Mothbot_Process repo code | **No explicit license** | No `LICENSE` file, no license field in `pyproject.toml`, no mention in `README.md`. Defaults to "all rights reserved" under copyright law. | +| AMI Data Companion | AGPL-3.0 | Main branch since PR #137. | + +**Implication:** weights and ultralytics are cleanly compatible with our AGPL-3.0 project. Mothbot's *code* is unlicensed — we will **not** verbatim-port their files. The detection wrapper is re-implemented in our codebase. The one snippet we do adapt (the PyTorch 2.6 `weights_only_load` fallback, `Mothbot_Process/pipeline/detect.py:44-97`) is boilerplate ultralytics compatibility handling; it will be attributed in a code comment as "adapted from Mothbot_Process/pipeline/detect.py — pattern is standard ultralytics PyTorch 2.6 compat". + +### Weights upload (operator step, pre-merge) + +User runs (not automated by this PR): + +```bash +AWS_PROFILE=ami python3 -c " +import boto3 +from botocore.config import Config + +s3 = boto3.client( + 's3', + endpoint_url='https://object-arbutus.cloud.computecanada.ca', + config=Config(request_checksum_calculation='when_required'), +) +s3.upload_file( + 'src-reference/Mothbot_Process/trained_models/yolo11m_4500_imgsz1600_b1_2024-01-18.pt', + 'ami-models', + 'mothbot/detection/yolo11m_4500_imgsz1600_b1_2024-01-18.pt', +) +" +``` + +Matches the pattern from the memory note on Arbutus (AWS CLI broken on this host; boto3 + `request_checksum_calculation='when_required'` needed for this endpoint). + +Verify accessibility after upload: + +```bash +curl -sI "https://object-arbutus.cloud.computecanada.ca/ami-models/mothbot/detection/yolo11m_4500_imgsz1600_b1_2024-01-18.pt" | head +``` + +## Tests + +1. **Unit — `post_process_single`**: construct a fake ultralytics Result with two known OBB entries (hand-computed 4-corner points); assert `YoloDetection` fields match expected min/max envelope, rotation, and score. +2. **Integration — end-to-end pipeline**: feed one test image from `trapdata/tests/images/` through the new pipeline; assert ≥1 detection and ≥1 classification; follow existing skip patterns for tests that need downloadable weights. +3. **Rename regression**: existing tests for other pipelines continue to pass after the `CLASSIFIER_CHOICES` → `PIPELINE_CHOICES` rename (they already use the dict, just via a new name). + +**Not in this PR:** YOLO accuracy eval, OBB correctness at scale, multi-image batching throughput. Those belong in a separate evaluation task. + +## Rollout + +0. **Rebase** `worktree-mothbot-pipeline` onto current `origin/main` (uv + AGPL-3). Resolve any conflicts before starting. +1. **Upload weights** to Arbutus (operator step, above). Verify the URL is reachable. +2. **Commit 1**: rename `CLASSIFIER_CHOICES` → `PIPELINE_CHOICES` across the 7 files. No behavior change. +3. **Commit 2**: add `detector_cls` class attribute on `APIMothClassifier` with default `APIMothDetector`; swap hardcoded `APIMothDetector` at `api.py:140` and `api.py:221` for `Classifier.detector_cls`. No behavior change for existing pipelines. +4. **Commit 3**: add `ultralytics` dep via `uv add`. Lockfile churn isolated. +5. **Commit 4**: add YOLO detector ML class + `YoloDetection` dataclass + API wrapper + unit test for `post_process_single`. +6. **Commit 5**: add `MothbotInsectOrderClassifier` + register in `PIPELINE_CHOICES` + `rotation` field on `DetectionResponse` + integration test. + +Each commit must leave `pytest` passing on its own. + +## PR description checklist + +- **What**: new `mothbot_insect_orders_2025` pipeline. +- **Why**: users wanting a Mothbot-style detector paired with our order classifier. +- **Dependency**: ultralytics 8.3+ added (AGPL-3; project is already AGPL-3 — no escalation). YOLO weights carry embedded AGPL-3 metadata. Mothbot's code is unlicensed, so we re-implement rather than verbatim-port; one adapted snippet is attributed in a code comment. +- **Rotation field**: forward-looking addition; unused in this PR; proposed upgrade path outlined. +- **Single-class detector**: model outputs only `{0: "creature"}`; taxonomic labels come from the existing ConvNeXt classifier. +- **Rename** `CLASSIFIER_CHOICES` → `PIPELINE_CHOICES`: mechanical, one commit, reviewable alone. +- **Weights**: hosted on Arbutus at `ami-models/mothbot/detection/...`; download-on-first-run via existing `get_or_download_file()`. +- **Test plan**: unit + one integration test; accuracy eval deferred. + +## Open for follow-up (not this PR) + +- Mothbot pybioclip order/species classifier as a second new pipeline. +- Full `RotatedBoundingBox` schema + rotated crop support in `ClassificationImageDataset`, enabling a species classifier to use tighter rotated crops (where the rotation field would finally get read). +- YOLO detector evaluation against FasterRCNN on the same test set — accuracy, latency, GPU memory. diff --git a/pyproject.toml b/pyproject.toml index 51bf7fda..2600d6b3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ name = "trapdata" version = "0.6.0" description = "Companion software for automated insect monitoring stations" authors = [{ name = "Michael Bunsen", email = "notbot@gmail.com" }] -license = { text = "MIT" } +license = { text = "AGPL-3.0" } readme = "README.md" requires-python = ">=3.10,<3.13" urls = { Homepage = "https://github.com/RolnickLab/ami-data-companion", Repository = "https://github.com/RolnickLab/ami-data-companion" } @@ -31,6 +31,7 @@ dependencies = [ "torch>=2.5", "torchvision>=0.20", "typer>=0.12.3,<1", + "ultralytics>=8.3", "uvicorn>=0.20", ] diff --git a/trapdata/antenna/client.py b/trapdata/antenna/client.py index 5e8cde6a..ec827e58 100644 --- a/trapdata/antenna/client.py +++ b/trapdata/antenna/client.py @@ -32,6 +32,7 @@ def get_jobs( base_url: str, auth_token: str, pipeline_slugs: list[str], + project_ids: list[int] | None = None, ) -> list[tuple[int, str]]: """Fetch job ids from the API for the given pipelines in a single request. @@ -41,6 +42,8 @@ def get_jobs( base_url: Antenna API base URL (e.g., "http://localhost:8000/api/v2") auth_token: API authentication token pipeline_slugs: List of pipeline slugs to filter jobs + project_ids: Optional list of project IDs to limit jobs to. + If empty or None, pulls jobs for all projects the token has access to. Returns: List of (job_id, pipeline_slug) tuples (possibly empty) on success or error. @@ -50,12 +53,14 @@ def get_jobs( if not pipeline_slugs: return [] url = f"{base_url.rstrip('/')}/jobs" - params = { + params: dict[str, str | int] = { "pipeline__slug__in": ",".join(pipeline_slugs), "ids_only": 1, "incomplete_only": 1, "dispatch_mode": JobDispatchMode.ASYNC_API, # Only fetch async_api jobs } + if project_ids: + params["project__id__in"] = ",".join(str(pid) for pid in project_ids) resp = session.get(url, params=params, timeout=30) resp.raise_for_status() diff --git a/trapdata/antenna/registration.py b/trapdata/antenna/registration.py index 4b41e319..5a18dfc6 100644 --- a/trapdata/antenna/registration.py +++ b/trapdata/antenna/registration.py @@ -7,7 +7,7 @@ AsyncPipelineRegistrationRequest, AsyncPipelineRegistrationResponse, ) -from trapdata.api.api import CLASSIFIER_CHOICES, initialize_service_info +from trapdata.api.api import initialize_service_info from trapdata.api.utils import get_http_session from trapdata.common.logs import logger from trapdata.settings import Settings, read_settings @@ -48,7 +48,24 @@ def register_pipelines_for_project( response.raise_for_status() result = AsyncPipelineRegistrationResponse.model_validate(response.json()) - return True, f"Created {len(result.pipelines_created)} new pipelines" + parts = [] + if result.processing_service_id: + parts.append(f"Processing service ID {result.processing_service_id}") + if result.pipelines_created: + parts.append( + f"created {len(result.pipelines_created)} pipelines " + f"({', '.join(result.pipelines_created)})" + ) + if result.pipelines_updated: + parts.append( + f"updated {len(result.pipelines_updated)} pipelines " + f"({', '.join(result.pipelines_updated)})" + ) + if not result.pipelines_created and not result.pipelines_updated: + parts.append( + f"all {len(pipeline_configs)} pipelines already registered" + ) + return True, "; ".join(parts) except requests.RequestException as e: if ( @@ -72,6 +89,7 @@ def register_pipelines( project_ids: list[int], service_name: str, settings: Settings | None = None, + pipeline_slugs: list[str] | None = None, ) -> None: """ Register pipelines for specified projects or all accessible projects. @@ -80,6 +98,8 @@ def register_pipelines( project_ids: List of specific project IDs to register for. If empty, registers for all accessible projects. service_name: Name of the processing service settings: Settings object with antenna_api_* configuration (defaults to read_settings()) + pipeline_slugs: Optional list of pipeline slugs to register. If None or empty, + registers all available pipelines. """ # Import here to avoid circular import from trapdata.antenna.client import get_user_projects @@ -128,13 +148,22 @@ def register_pipelines( logger.info("Initializing pipeline configurations...") service_info = initialize_service_info() pipeline_configs = service_info.pipelines - logger.info(f"Generated {len(pipeline_configs)} pipeline configurations") + + # Filter to requested pipelines if specified + if pipeline_slugs: + slug_set = set(pipeline_slugs) + pipeline_configs = [p for p in pipeline_configs if p.slug in slug_set] + logger.info( + f"Filtered to {len(pipeline_configs)} pipelines: {', '.join(pipeline_slugs)}" + ) + else: + logger.info(f"Registering all {len(pipeline_configs)} pipeline configurations") # Register pipelines for each project successful_registrations = [] failed_registrations = [] - logger.info(f"Available pipelines to register: {list(CLASSIFIER_CHOICES.keys())}") + logger.info(f"Pipelines to register: {[p.slug for p in pipeline_configs]}") for project in projects_to_process: project_id = project["id"] @@ -164,10 +193,12 @@ def register_pipelines( # Summary report logger.info("\n=== Registration Summary ===") - logger.info(f"Service name: {full_service_name}") - logger.info(f"Total projects processed: {len(projects_to_process)}") - logger.info(f"Successful registrations: {len(successful_registrations)}") - logger.info(f"Failed registrations: {len(failed_registrations)}") + logger.info(f"Processing service: {full_service_name}") + logger.info(f"Pipelines advertised: {len(pipeline_configs)}") + logger.info(f"Projects processed: {len(projects_to_process)}") + logger.info( + f"Successful: {len(successful_registrations)}, Failed: {len(failed_registrations)}" + ) if successful_registrations: logger.info("\nSuccessful registrations:") diff --git a/trapdata/antenna/worker.py b/trapdata/antenna/worker.py index 2b7e1db6..163de7d2 100644 --- a/trapdata/antenna/worker.py +++ b/trapdata/antenna/worker.py @@ -14,7 +14,7 @@ from trapdata.antenna.datasets import CUDAPrefetcher, get_rest_dataloader from trapdata.antenna.result_posting import ResultPoster from trapdata.antenna.schemas import AntennaTaskResult, AntennaTaskResultError -from trapdata.api.api import CLASSIFIER_CHOICES, should_filter_detections +from trapdata.api.api import PIPELINE_CHOICES, should_filter_detections from trapdata.api.models.classification import MothClassifierBinary from trapdata.api.models.localization import APIMothDetector from trapdata.api.schemas import ( @@ -30,13 +30,19 @@ SLEEP_TIME_SECONDS = 5 -def run_worker(pipelines: list[str]): +def run_worker(pipelines: list[str], project_ids: list[int] | None = None): """Run the worker to process images from the REST API queue. Automatically spawns one AMI worker instance process per available GPU. On single-GPU or CPU-only machines, runs in-process (no overhead). + + Args: + pipelines: Pipeline slugs to poll for jobs. + project_ids: Optional project IDs to limit jobs to. If empty/None, + pulls jobs for all projects the auth token has access to. """ settings = read_settings() + project_ids = project_ids or [] # Validate auth token if not settings.antenna_api_auth_token: @@ -59,7 +65,7 @@ def run_worker(pipelines: list[str]): # can't be pickled. Each child process calls read_settings() itself. mp.spawn( _worker_loop, - args=(pipelines,), + args=(pipelines, project_ids), nprocs=gpu_count, join=True, ) @@ -68,17 +74,21 @@ def run_worker(pipelines: list[str]): logger.info(f"Found 1 GPU: {torch.cuda.get_device_name(0)}") else: logger.info("No GPUs found, running on CPU") - _worker_loop(0, pipelines) + _worker_loop(0, pipelines, project_ids) -def _worker_loop(gpu_id: int, pipelines: list[str]): +def _worker_loop( + gpu_id: int, pipelines: list[str], project_ids: list[int] | None = None +): """Main polling loop for a single AMI worker instance, pinned to a specific GPU. Args: gpu_id: GPU index to pin this AMI worker instance to (0 for CPU-only). pipelines: List of pipeline slugs to poll for jobs. + project_ids: Optional project IDs to limit jobs to. """ settings = read_settings() + project_ids = project_ids or [] device = torch.device(f"cuda:{gpu_id}" if torch.cuda.is_available() else "cpu") if torch.cuda.is_available() and torch.cuda.device_count() > 0: torch.cuda.set_device(gpu_id) @@ -89,6 +99,8 @@ def _worker_loop(gpu_id: int, pipelines: list[str]): # Build full service name with hostname full_service_name = get_full_service_name(settings.antenna_service_name) logger.info(f"Running worker as: {full_service_name}") + if project_ids: + logger.info(f"Filtering jobs to projects: {project_ids}") while True: # TODO CGJS: Support pulling and prioritizing single image tasks, which are used in interactive testing @@ -102,6 +114,7 @@ def _worker_loop(gpu_id: int, pipelines: list[str]): base_url=settings.antenna_api_base_url, auth_token=settings.antenna_api_auth_token, pipeline_slugs=pipelines, + project_ids=project_ids if project_ids else None, ) for job_id, pipeline in jobs: logger.info( @@ -303,12 +316,21 @@ def _process_batch( for idx, dresp in enumerate(detections_for_terminal_classifier): image_tensor = image_tensors[dresp.source_image_id] bbox = dresp.bbox - y1, y2 = int(bbox.y1), int(bbox.y2) - x1, x2 = int(bbox.x1), int(bbox.x2) + _, img_h, img_w = image_tensor.shape + # Clamp bbox to image bounds before using as slice indices. YOLO-OBB + # can produce negative coords for detections near image edges; PyTorch + # slicing would treat negatives as end-relative indices, yielding an + # empty crop and an H=0/W=0 Resize error downstream. + y1 = max(0, min(int(bbox.y1), img_h)) + y2 = max(0, min(int(bbox.y2), img_h)) + x1 = max(0, min(int(bbox.x1), img_w)) + x2 = max(0, min(int(bbox.x2), img_w)) if y1 >= y2 or x1 >= x2: logger.warning( - f"Skipping detection {idx} with invalid bbox: " - f"({x1},{y1})->({x2},{y2})" + f"Skipping detection {idx} with invalid bbox after clamping " + f"to image {img_w}x{img_h}: ({x1},{y1})->({x2},{y2}) " + f"(raw: x1={bbox.x1:.1f} y1={bbox.y1:.1f} " + f"x2={bbox.x2:.1f} y2={bbox.y2:.1f})" ) continue crop = image_tensor[:, y1:y2, x1:x2] @@ -425,7 +447,7 @@ def _process_job( detector = None # Check if binary filtering is needed once for the entire job - classifier_class = CLASSIFIER_CHOICES[pipeline] + classifier_class = PIPELINE_CHOICES[pipeline] use_binary_filter = should_filter_detections(classifier_class) binary_filter = None @@ -464,7 +486,7 @@ def _process_job( # Defer instantiation of poster, detector and classifiers until we have data if not classifier: classifier = classifier_class(source_images=[], detections=[]) - detector = APIMothDetector([]) + detector = classifier_class.detector_cls([]) result_poster = ResultPoster(max_pending=MAX_PENDING_POSTS) if use_binary_filter: diff --git a/trapdata/api/api.py b/trapdata/api/api.py index 47f34fec..3bdaae8e 100644 --- a/trapdata/api/api.py +++ b/trapdata/api/api.py @@ -16,6 +16,8 @@ from .models.classification import ( APIMothClassifier, InsectOrderClassifier, + MothbotInsectOrderClassifier, + MothbotMothClassifierPanama, MothClassifierBinary, MothClassifierGlobal, MothClassifierPanama, @@ -52,7 +54,7 @@ async def lifespan(app: fastapi.FastAPI): app.add_middleware(GZipMiddleware) -CLASSIFIER_CHOICES = { +PIPELINE_CHOICES = { "panama_moths_2023": MothClassifierPanama, "panama_moths_2024": MothClassifierPanama2024, "quebec_vermont_moths_2023": MothClassifierQuebecVermont, @@ -63,20 +65,23 @@ async def lifespan(app: fastapi.FastAPI): "global_moths_2024": MothClassifierGlobal, "moth_binary": MothClassifierBinary, "insect_orders_2025": InsectOrderClassifier, + "mothbot_insect_orders_2025": MothbotInsectOrderClassifier, + "mothbot_panama_moths_2023": MothbotMothClassifierPanama, } -_classifier_choices = dict( - zip(CLASSIFIER_CHOICES.keys(), list(CLASSIFIER_CHOICES.keys())) -) +_classifier_choices = dict(zip(PIPELINE_CHOICES.keys(), list(PIPELINE_CHOICES.keys()))) PipelineChoice = enum.Enum("PipelineChoice", _classifier_choices) def should_filter_detections(Classifier: type[APIMothClassifier]) -> bool: - if Classifier in [MothClassifierBinary, InsectOrderClassifier]: + # Classifiers that skip the binary moth/non-moth prefilter: the binary + # classifier itself (there's nothing downstream to filter for), and any + # order-level classifier (it already distinguishes non-moth insects, + # so a binary prefilter would discard signal). + if issubclass(Classifier, (MothClassifierBinary, InsectOrderClassifier)): return False - else: - return True + return True def make_category_map_response( @@ -137,7 +142,7 @@ def make_pipeline_config_response( """ algorithms = [] - detector = APIMothDetector( + detector = Classifier.detector_cls( source_images=[], ) algorithms.append(make_algorithm_config_response(detector)) @@ -159,10 +164,18 @@ def make_pipeline_config_response( ) algorithms.append(make_algorithm_config_response(classifier)) + # Prefer a pipeline-level description when the classifier supplies one + # (describes the full detector+classifier combo). Otherwise fall back to + # the classifier's own description, which is what every other pipeline + # currently ships. + pipeline_description = ( + getattr(classifier, "pipeline_description", None) or classifier.description + ) + return PipelineConfigResponse( name=classifier.name, slug=slug, - description=classifier.description, + description=pipeline_description, version=1, algorithms=algorithms, ) @@ -216,9 +229,9 @@ async def process(data: PipelineRequest) -> PipelineResponse: start_time = time.time() - Classifier = CLASSIFIER_CHOICES[str(data.pipeline)] + Classifier = PIPELINE_CHOICES[str(data.pipeline)] - detector = APIMothDetector( + detector = Classifier.detector_cls( source_images=source_images, batch_size=settings.localization_batch_size, num_workers=settings.num_workers, @@ -359,7 +372,7 @@ def initialize_service_info() -> ProcessingServiceInfoResponse: # @TODO This requires loading all models into memory! Can we avoid this? pipeline_configs = [ make_pipeline_config_response(classifier_class, slug=key) - for key, classifier_class in CLASSIFIER_CHOICES.items() + for key, classifier_class in PIPELINE_CHOICES.items() ] _info = ProcessingServiceInfoResponse( diff --git a/trapdata/api/models/classification.py b/trapdata/api/models/classification.py index e604f3c8..3632f48b 100644 --- a/trapdata/api/models/classification.py +++ b/trapdata/api/models/classification.py @@ -28,6 +28,7 @@ SourceImage, ) from .base import APIInferenceBaseClass +from .localization import APIMothDetector, APIMothDetector_YOLO11m_Mothbot class APIMothClassifier( @@ -36,6 +37,19 @@ class APIMothClassifier( ): task_type = "classification" + # The detector class this pipeline pairs with. Subclasses override + # to pair a specific classifier with a specific detector. Default is + # the FasterRCNN 2023 detector that all existing pipelines use. + detector_cls: type[APIInferenceBaseClass] = APIMothDetector + + # Optional pipeline-level description, distinct from the classifier + # algorithm's own description. api.make_pipeline_config_response uses + # this (when set) for PipelineConfigResponse.description, while the + # classifier algorithm row still carries `self.description`. Lets us + # describe the full detector+classifier combo at the pipeline level + # without polluting the classifier algorithm's metadata. + pipeline_description: str | None = None + def __init__( self, source_images: typing.Iterable[SourceImage], @@ -231,3 +245,41 @@ class MothClassifierGlobal(APIMothClassifier, GlobalMothSpeciesClassifier): class InsectOrderClassifier(APIMothClassifier, InsectOrderClassifier2025): pass + + +class MothbotInsectOrderClassifier(InsectOrderClassifier): + """Pair the Mothbot YOLO11m-OBB detector with our existing Mila ConvNeXt-T + order classifier. Overrides the default detector_cls inherited from + APIMothClassifier. + + The ``name`` is distinct from the parent so Antenna's pipeline registry + (which keys on name) treats this as a separate pipeline rather than + deduping against ``insect_orders_2025``. + """ + + name = "Mothbot YOLO + Insect Orders 2025" + detector_cls = APIMothDetector_YOLO11m_Mothbot + pipeline_description = ( + "Mothbot YOLO11m-OBB creature detector (Digital Naturalism " + "Laboratories, Jan 2024) feeding the Mila ConvNeXt-T insect order " + "classifier (16 orders, Jan 2025). Binary moth/non-moth prefilter " + "is skipped since the order classifier already distinguishes " + "non-moth insects." + ) + + +class MothbotMothClassifierPanama(MothClassifierPanama): + """Pair the Mothbot YOLO11m-OBB detector with the 2023 Panama species + classifier (mixed resolution). Overrides the default detector_cls + inherited from APIMothClassifier; retains the binary moth/non-moth + prefilter because the terminal classifier only covers moth species. + """ + + name = "Mothbot YOLO + Panama Moths 2023" + detector_cls = APIMothDetector_YOLO11m_Mothbot + pipeline_description = ( + "Mothbot YOLO11m-OBB creature detector (Digital Naturalism " + "Laboratories, Jan 2024) -> binary moth/non-moth prefilter " + "(Mila, 2024) -> 2023 Panama moth species classifier " + "(mixed-resolution, 148 species)." + ) diff --git a/trapdata/api/models/localization.py b/trapdata/api/models/localization.py index 9ec1acd5..b6856a99 100644 --- a/trapdata/api/models/localization.py +++ b/trapdata/api/models/localization.py @@ -1,7 +1,10 @@ import datetime import typing -from trapdata.ml.models.localization import MothObjectDetector_FasterRCNN_2023 +from trapdata.ml.models.localization import ( + MothObjectDetector_FasterRCNN_2023, + MothObjectDetector_YOLO11m_Mothbot, +) from ..datasets import LocalizationImageDataset from ..schemas import AlgorithmReference, BoundingBox, DetectionResponse, SourceImage @@ -56,3 +59,53 @@ def save_detection(image_id, coords): def run(self) -> list[DetectionResponse]: super().run() return self.results + + +class APIMothDetector_YOLO11m_Mothbot( + APIInferenceBaseClass, MothObjectDetector_YOLO11m_Mothbot +): + task_type = "localization" + + def __init__(self, source_images: typing.Iterable[SourceImage], *args, **kwargs): + self.source_images = source_images + self.results: list[DetectionResponse] = [] + super().__init__(*args, **kwargs) + + def reset(self, source_images: typing.Iterable[SourceImage]): + self.source_images = source_images + self.results = [] + + def get_dataset(self): + return LocalizationImageDataset( + self.source_images, self.get_transforms(), batch_size=self.batch_size + ) + + def get_source_image(self, source_image_id: int) -> SourceImage: + for source_image in self.source_images: + if source_image.id == source_image_id: + return source_image + raise ValueError(f"Source image with id {source_image_id} not found") + + def save_results(self, item_ids, batch_output, seconds_per_item, *args, **kwargs): + """batch_output is a list (one per image) of list[YoloDetection].""" + detections: list[DetectionResponse] = [] + for image_id, yolo_dets in zip(item_ids, batch_output): + for y in yolo_dets: + detections.append( + DetectionResponse( + source_image_id=image_id, + bbox=BoundingBox(x1=y.x1, y1=y.y1, x2=y.x2, y2=y.y2), + rotation=y.rotation, + inference_time=seconds_per_item, + algorithm=AlgorithmReference( + name=self.name, key=self.get_key() + ), + timestamp=datetime.datetime.now(), + crop_image_url=None, + ) + ) + self.results += detections + + def run(self) -> list[DetectionResponse]: + super().run() + return self.results diff --git a/trapdata/api/schemas.py b/trapdata/api/schemas.py index a8b682ac..95fc6fbd 100644 --- a/trapdata/api/schemas.py +++ b/trapdata/api/schemas.py @@ -114,6 +114,17 @@ class DetectionResponse(pydantic.BaseModel): timestamp: datetime.datetime crop_image_url: str | None = None classifications: list[ClassificationResponse] = [] + rotation: float | None = pydantic.Field( + default=None, + description=( + "Rotation angle in degrees (cv2.minAreaRect convention), when " + "the detector produces oriented bounding boxes. FUTURE: " + "downstream classifiers may use this to crop a straightened " + "patch instead of the axis-aligned envelope. See " + "`docs/superpowers/specs/2026-04-14-mothbot-detection-pipeline-design.md` " + "for the proposed RotatedBoundingBox schema upgrade." + ), + ) class SourceImageRequest(pydantic.BaseModel): diff --git a/trapdata/api/tests/test_api.py b/trapdata/api/tests/test_api.py index 84dba6c7..6304ad1c 100644 --- a/trapdata/api/tests/test_api.py +++ b/trapdata/api/tests/test_api.py @@ -5,7 +5,7 @@ from fastapi.testclient import TestClient from trapdata.api.api import ( - CLASSIFIER_CHOICES, + PIPELINE_CHOICES, PipelineChoice, PipelineRequest, PipelineResponse, @@ -15,7 +15,7 @@ ) from trapdata.api.schemas import PipelineConfigRequest from trapdata.api.tests.image_server import StaticFileTestServer -from trapdata.api.tests.utils import get_test_images, get_pipeline_class +from trapdata.api.tests.utils import get_pipeline_class, get_test_images from trapdata.tests import TEST_IMAGES_BASE_PATH logging.basicConfig(level=logging.INFO) @@ -62,12 +62,12 @@ def test_pipeline_request(self): def test_pipeline_config_with_binary_classifier(self): binary_classifier_pipeline_choice = "moth_binary" - BinaryClassifier = CLASSIFIER_CHOICES[binary_classifier_pipeline_choice] + BinaryClassifier = PIPELINE_CHOICES[binary_classifier_pipeline_choice] binary_classifier_instance = BinaryClassifier(source_images=[], detections=[]) BinaryClassifierResponse = make_algorithm_response(binary_classifier_instance) species_classifier_pipeline_choice = "quebec_vermont_moths_2023" - SpeciesClassifier = CLASSIFIER_CHOICES[species_classifier_pipeline_choice] + SpeciesClassifier = PIPELINE_CHOICES[species_classifier_pipeline_choice] species_classifier_instance = SpeciesClassifier(source_images=[], detections=[]) SpeciesClassifierResponse = make_algorithm_response(species_classifier_instance) @@ -99,7 +99,7 @@ def test_pipeline_config_with_binary_classifier(self): def test_processing_with_only_binary_classifier(self): binary_classifier_pipeline_choice = "moth_binary" binary_algorithm_key = "moth_nonmoth_classifier" - BinaryAlgorithmClass = CLASSIFIER_CHOICES[binary_classifier_pipeline_choice] + BinaryAlgorithmClass = PIPELINE_CHOICES[binary_classifier_pipeline_choice] # Create an instance to get the num_classes binary_algorithm = BinaryAlgorithmClass(source_images=[], detections=[]) @@ -231,3 +231,78 @@ def test_config_num_classification_predictions(self): f"Number of logits ({len(classification.logits)}) should equal " f"number of classes ({num_classes})" ) + + def test_existing_pipelines_default_to_apimothdetector(self): + """Pre-existing pipelines must keep using APIMothDetector. + + New pipelines introduced with their own detector are exempt. + """ + from trapdata.api.api import PIPELINE_CHOICES + from trapdata.api.models.localization import APIMothDetector + + exempt = {"mothbot_insect_orders_2025"} + for slug, Classifier in PIPELINE_CHOICES.items(): + if slug in exempt: + continue + self.assertIs( + Classifier.detector_cls, + APIMothDetector, + f"{slug} should default to APIMothDetector", + ) + + def test_mothbot_pipeline_uses_yolo_detector(self): + from trapdata.api.api import PIPELINE_CHOICES + from trapdata.api.models.localization import APIMothDetector_YOLO11m_Mothbot + + assert "mothbot_insect_orders_2025" in PIPELINE_CHOICES + Classifier = PIPELINE_CHOICES["mothbot_insect_orders_2025"] + self.assertIs(Classifier.detector_cls, APIMothDetector_YOLO11m_Mothbot) + + def test_mothbot_pipeline_skips_binary_filter(self): + from trapdata.api.api import PIPELINE_CHOICES, should_filter_detections + + Classifier = PIPELINE_CHOICES["mothbot_insect_orders_2025"] + self.assertFalse(should_filter_detections(Classifier)) + + def test_detection_response_has_optional_rotation_field(self): + """The rotation field is opt-in for detectors that produce OBB.""" + import datetime + + from trapdata.api.schemas import ( + AlgorithmReference, + BoundingBox, + DetectionResponse, + ) + + # Default: rotation is None + d = DetectionResponse( + source_image_id="img1", + bbox=BoundingBox(x1=0, y1=0, x2=10, y2=10), + algorithm=AlgorithmReference(name="x", key="x"), + timestamp=datetime.datetime.now(), + ) + self.assertIsNone(d.rotation) + + # Accepts a float + d2 = DetectionResponse( + source_image_id="img1", + bbox=BoundingBox(x1=0, y1=0, x2=10, y2=10), + algorithm=AlgorithmReference(name="x", key="x"), + timestamp=datetime.datetime.now(), + rotation=-42.5, + ) + self.assertAlmostEqual(d2.rotation, -42.5) + + def test_yolo_api_detector_instantiates(self): + """The new YOLO detector wrapper should construct with no source images + (matches the pattern the /info handler uses to read algorithm metadata). + + This test exercises weight download + model load — it will be slow on + first run but cached thereafter. + """ + from trapdata.api.models.localization import APIMothDetector_YOLO11m_Mothbot + + detector = APIMothDetector_YOLO11m_Mothbot(source_images=[]) + self.assertEqual(detector.name, "Mothbot YOLO11m Creature Detector") + self.assertEqual(detector.category_map, {0: "creature"}) + self.assertEqual(detector.imgsz, 1600) diff --git a/trapdata/api/tests/test_mothbot_pipeline.py b/trapdata/api/tests/test_mothbot_pipeline.py new file mode 100644 index 00000000..2680ca2e --- /dev/null +++ b/trapdata/api/tests/test_mothbot_pipeline.py @@ -0,0 +1,139 @@ +"""Integration tests for the Mothbot YOLO pipelines. + +Covers both pipeline slugs that pair the Mothbot YOLO11m-OBB detector +with an existing terminal classifier: + + - ``mothbot_insect_orders_2025``: YOLO -> Insect Order classifier + (binary prefilter skipped). + - ``mothbot_panama_moths_2023``: YOLO -> binary moth/non-moth + prefilter -> Panama 2023 moth species classifier. + +Each test will download the YOLO weights (~40 MB) plus the terminal +classifier weights on first run, then cache them. Tests are loose +about content; they assert the pipeline runs and the detector populates +the new ``rotation`` field. Accuracy is out of scope for this suite. +""" + +import logging +import pathlib +from unittest import TestCase + +from fastapi.testclient import TestClient + +from trapdata.api.api import PipelineChoice, PipelineRequest, PipelineResponse, app +from trapdata.api.tests.image_server import StaticFileTestServer +from trapdata.api.tests.utils import get_test_images +from trapdata.tests import TEST_IMAGES_BASE_PATH + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +class TestMothbotPipeline(TestCase): + @classmethod + def setUpClass(cls): + cls.test_images_dir = pathlib.Path(TEST_IMAGES_BASE_PATH) + if not cls.test_images_dir.exists(): + raise FileNotFoundError( + f"Test images directory not found: {cls.test_images_dir}" + ) + cls.file_server = StaticFileTestServer(cls.test_images_dir) + cls.client = TestClient(app) + + @classmethod + def tearDownClass(cls): + if hasattr(cls, "file_server"): + cls.file_server.stop() + + def test_mothbot_pipeline_end_to_end(self): + """Send one vermont test image through the new pipeline.""" + test_images = get_test_images( + self.file_server, self.test_images_dir, subdir="vermont", num=1 + ) + assert test_images, "No test images found" + + pipeline_request = PipelineRequest( + pipeline=PipelineChoice["mothbot_insect_orders_2025"], + source_images=test_images, + ) + with self.file_server: + response = self.client.post("/process", json=pipeline_request.model_dump()) + self.assertEqual( + response.status_code, 200, f"Unexpected status: {response.text[:500]}" + ) + + result = PipelineResponse(**response.json()) + self.assertTrue(result.detections, "pipeline returned no detections") + + # At least one detection should carry a rotation (YOLO-OBB populates it) + rotations = [d.rotation for d in result.detections] + self.assertTrue( + any(r is not None for r in rotations), + "YOLO detector should populate the rotation field on at least one " + "detection", + ) + + # Each detection should have an order classification from the terminal + # classifier. (Binary prefilter is skipped for this pipeline.) + for detection in result.detections: + terminal = [c for c in detection.classifications if c.terminal] + self.assertTrue( + terminal, + f"detection {detection.bbox} has no terminal classification", + ) + self.assertEqual( + terminal[0].algorithm.key, + "mothbot_yolo_insect_orders_2025", + f"expected order classifier, got {terminal[0].algorithm.key}", + ) + + def test_mothbot_panama_pipeline_end_to_end(self): + """YOLO -> binary prefilter -> Panama 2023 species classifier.""" + test_images = get_test_images( + self.file_server, self.test_images_dir, subdir="vermont", num=1 + ) + assert test_images, "No test images found" + + pipeline_request = PipelineRequest( + pipeline=PipelineChoice["mothbot_panama_moths_2023"], + source_images=test_images, + ) + with self.file_server: + response = self.client.post("/process", json=pipeline_request.model_dump()) + self.assertEqual( + response.status_code, 200, f"Unexpected status: {response.text[:500]}" + ) + + result = PipelineResponse(**response.json()) + self.assertTrue(result.detections, "pipeline returned no detections") + + # YOLO detector still populates rotation + rotations = [d.rotation for d in result.detections] + self.assertTrue( + any(r is not None for r in rotations), + "YOLO detector should populate rotation on at least one detection", + ) + + # Each detection should carry a binary classification (prefilter is + # terminal=False) and a Panama terminal classification *only for + # detections the binary filter called a moth*. Detections classified + # as non-moth short-circuit after the binary step. + for detection in result.detections: + algo_keys = [c.algorithm.key for c in detection.classifications] + self.assertIn( + "moth_nonmoth_classifier", + algo_keys, + f"missing binary classification on {detection.bbox}: {algo_keys}", + ) + + terminal_keys = { + c.algorithm.key + for d in result.detections + for c in d.classifications + if c.terminal + } + self.assertIn( + "mothbot_yolo_panama_moths_2023", + terminal_keys, + f"expected Panama species classifier terminal, got {terminal_keys}", + ) diff --git a/trapdata/api/tests/utils.py b/trapdata/api/tests/utils.py index eda17bc5..eb8dd917 100644 --- a/trapdata/api/tests/utils.py +++ b/trapdata/api/tests/utils.py @@ -7,7 +7,7 @@ from fastapi.testclient import TestClient -from trapdata.api.api import CLASSIFIER_CHOICES, APIMothClassifier +from trapdata.api.api import PIPELINE_CHOICES, APIMothClassifier from trapdata.api.schemas import SourceImageRequest from trapdata.api.tests.image_server import StaticFileTestServer @@ -72,7 +72,7 @@ def get_pipeline_class( Returns: APIMothClassifier class for the specified pipeline """ - return CLASSIFIER_CHOICES[slug] + return PIPELINE_CHOICES[slug] @contextmanager diff --git a/trapdata/cli/base.py b/trapdata/cli/base.py index 59c69e8a..aae00bb6 100644 --- a/trapdata/cli/base.py +++ b/trapdata/cli/base.py @@ -1,9 +1,8 @@ import pathlib -from typing import Annotated, Optional +from typing import Optional import typer -from trapdata.api.api import CLASSIFIER_CHOICES from trapdata.cli import db, export, queue, settings, shell, show, test, worker from trapdata.db.base import get_session_class from trapdata.db.models.events import get_or_create_monitoring_sessions diff --git a/trapdata/cli/worker.py b/trapdata/cli/worker.py index f1b5782e..302b2d48 100644 --- a/trapdata/cli/worker.py +++ b/trapdata/cli/worker.py @@ -4,21 +4,54 @@ import typer -from trapdata.api.api import CLASSIFIER_CHOICES +from trapdata.api.api import PIPELINE_CHOICES cli = typer.Typer(help="Antenna worker commands for remote processing") +_PIPELINE_HELP = ( + "Pipeline to use for processing (e.g., moth_binary, panama_moths_2024). " + "Can be specified multiple times. Defaults to all pipelines if not specified." +) +_PROJECT_HELP = ( + "Limit to jobs for specific Antenna project IDs. " + "Can be specified multiple times. Defaults to all projects the auth token has access to." +) + + +def _validate_pipelines(pipelines: list[str] | None) -> list[str]: + """Resolve and validate the --pipeline option.""" + if not pipelines: + return list(PIPELINE_CHOICES.keys()) + + invalid = [p for p in pipelines if p not in PIPELINE_CHOICES] + if invalid: + raise typer.BadParameter( + f"Invalid pipeline(s): {', '.join(invalid)}. " + f"Must be one of: {', '.join(PIPELINE_CHOICES.keys())}" + ) + return pipelines + + +def _start_worker(pipelines: list[str] | None, project: list[int] | None) -> None: + """Shared implementation for ``ami worker`` and ``ami worker run``.""" + validated = _validate_pipelines(pipelines) + project_ids = project or [] + + from trapdata.antenna.worker import run_worker + + run_worker(pipelines=validated, project_ids=project_ids) + @cli.callback(invoke_without_command=True) -def run( +def worker_callback( ctx: typer.Context, pipelines: Annotated[ list[str] | None, - typer.Option( - "--pipeline", - help="Pipeline to use for processing (e.g., moth_binary, panama_moths_2024). Can be specified multiple times. " - "Defaults to all pipelines if not specified.", - ), + typer.Option("--pipeline", help=_PIPELINE_HELP), + ] = None, + project: Annotated[ + list[int] | None, + typer.Option("--project", help=_PROJECT_HELP), ] = None, ): """ @@ -26,26 +59,28 @@ def run( Can be invoked as 'ami worker' or 'ami worker run'. """ - # Only run the worker if no subcommand was invoked if ctx.invoked_subcommand is not None: return + _start_worker(pipelines, project) - if not pipelines: - pipelines = list(CLASSIFIER_CHOICES.keys()) - - # Validate that each pipeline is in CLASSIFIER_CHOICES - invalid_pipelines = [ - pipeline for pipeline in pipelines if pipeline not in CLASSIFIER_CHOICES.keys() - ] - if invalid_pipelines: - raise typer.BadParameter( - f"Invalid pipeline(s): {', '.join(invalid_pipelines)}. Must be one of: {', '.join(CLASSIFIER_CHOICES.keys())}" - ) - - from trapdata.antenna.worker import run_worker +@cli.command("run") +def run_cmd( + pipelines: Annotated[ + list[str] | None, + typer.Option("--pipeline", help=_PIPELINE_HELP), + ] = None, + project: Annotated[ + list[int] | None, + typer.Option("--project", help=_PROJECT_HELP), + ] = None, +): + """ + Run the worker to process images from the Antenna API queue. - run_worker(pipelines=pipelines) + Alias for 'ami worker' — both forms are identical. + """ + _start_worker(pipelines, project) @cli.command("register") @@ -53,29 +88,41 @@ def register( project: Annotated[ list[int] | None, typer.Option( + "--project", help="Specific project IDs to register pipelines for. " "If not specified, registers for all accessible projects.", ), ] = None, + pipelines: Annotated[ + list[str] | None, + typer.Option("--pipeline", help=_PIPELINE_HELP), + ] = None, ): """ Register available pipelines with the Antenna platform for specified projects. - This command registers all available pipeline configurations with the Antenna platform - for the specified projects (or all accessible projects if none specified). + This command registers the processing service and its pipeline configurations + with the Antenna platform for the specified projects (or all accessible projects + if none specified). + + When --pipeline is specified, only those pipelines are advertised (instead of all). The service name is read from the AMI_ANTENNA_SERVICE_NAME configuration setting. Hostname will be added automatically to the service name. Examples: ami worker register --project 1 --project 2 - ami worker register # registers for all accessible projects + ami worker register --pipeline mothbot_insect_orders_2025 + ami worker register # registers all pipelines for all accessible projects """ from trapdata.antenna.registration import register_pipelines from trapdata.settings import read_settings settings = read_settings() project_ids = project if project else [] + validated_pipelines = _validate_pipelines(pipelines) register_pipelines( - project_ids=project_ids, service_name=settings.antenna_service_name + project_ids=project_ids, + service_name=settings.antenna_service_name, + pipeline_slugs=validated_pipelines, ) diff --git a/trapdata/ml/models/localization.py b/trapdata/ml/models/localization.py index 3f5b427f..93fa934c 100644 --- a/trapdata/ml/models/localization.py +++ b/trapdata/ml/models/localization.py @@ -1,5 +1,8 @@ +import dataclasses import pathlib +import cv2 +import numpy as np import torch import torchvision import torchvision.models.detection.anchor_utils @@ -341,3 +344,273 @@ def post_process_single(self, output): bboxes = bboxes.cpu().numpy().astype(int).tolist() return bboxes + + +# ----------------------------------------------------------------------------- +# Mothbot YOLO11m-OBB detector +# +# Single-class ("creature") detector from Digital Naturalism Laboratories' +# Mothbot_Process project. Trained at imgsz=1600, Jan 2024. Weights are hosted +# on Arbutus alongside our other models. +# +# This implementation is an independent rewrite; Mothbot's repo is unlicensed +# (see docs/superpowers/specs/2026-04-14-mothbot-detection-pipeline-design.md). +# The torch 2.6 weights_only fallback below is adapted from +# Mothbot_Process/pipeline/detect.py -- the pattern is standard ultralytics +# PyTorch 2.6 compat handling, not Mothbot-specific logic. +# ----------------------------------------------------------------------------- + + +@dataclasses.dataclass(frozen=True) +class YoloDetection: + """One detection from the YOLO-OBB post-processor. + + Fields: + x1, y1, x2, y2: axis-aligned envelope of the rotated bounding box + (min/max of the 4 rotated corner points). + rotation: angle in degrees, cv2.minAreaRect convention. + score: detection confidence, in [0, 1]. + """ + + x1: float + y1: float + x2: float + y2: float + rotation: float + score: float + + +def _corners_to_yolo_detection( + corners: np.ndarray, + score: float, + image_shape: tuple[int, int] | None = None, +) -> YoloDetection: + """Convert 4 rotated corner points + score into a YoloDetection. + + Args: + corners: shape (4, 2), xy coordinates of the OBB corners. + score: detection confidence. + image_shape: (height, width) of the source image. When provided, the + axis-aligned envelope is clamped to [0, width] x [0, height]. YOLO-OBB + can emit corners outside the image when a detection touches an edge; + negative coords in particular are dangerous because PyTorch tensor + slicing treats negative indices as end-relative, yielding empty crops. + + Returns: + A YoloDetection with: + - (x1, y1, x2, y2): min/max envelope of the 4 corners (axis-aligned), + optionally clamped to image bounds. + - rotation: angle from cv2.minAreaRect (same convention Mothbot uses). + """ + pts = np.asarray(corners, dtype=np.float32).reshape(-1, 2) + x1, y1 = float(pts[:, 0].min()), float(pts[:, 1].min()) + x2, y2 = float(pts[:, 0].max()), float(pts[:, 1].max()) + if image_shape is not None: + h, w = image_shape + x1 = max(0.0, min(x1, float(w))) + x2 = max(0.0, min(x2, float(w))) + y1 = max(0.0, min(y1, float(h))) + y2 = max(0.0, min(y2, float(h))) + rect = cv2.minAreaRect(pts) + angle = float(rect[2]) + return YoloDetection(x1=x1, y1=y1, x2=x2, y2=y2, rotation=angle, score=float(score)) + + +def _tensor_to_bgr_numpy(t: torch.Tensor) -> np.ndarray: + """Convert a (C, H, W) RGB float tensor in [0, 1] to an (H, W, C) BGR uint8 + numpy array -- the format ultralytics YOLO was trained on (via cv2.imread). + """ + return (t.permute(1, 2, 0).cpu().numpy() * 255).astype(np.uint8)[..., ::-1] + + +def _load_ultralytics_yolo(weights_path): + """Load an ultralytics YOLO model with a PyTorch 2.6 weights_only fallback. + + Newer PyTorch defaults to torch.load(..., weights_only=True), which can + refuse to load Ultralytics checkpoints that embed custom model classes. + For local, trusted checkpoints we retry with weights_only=False. + + Adapted from Mothbot_Process/pipeline/detect.py (unlicensed repo; pattern + is standard ultralytics PyTorch 2.6 compat handling). + """ + import os + + import torch as _torch + from ultralytics import YOLO + + try: + return YOLO(str(weights_path)) + except Exception as err: + if "Weights only load failed" not in str(err): + raise + + logger.info( + "Retrying YOLO load with torch.load(weights_only=False) compatibility " + "(trusted local checkpoint)" + ) + original_load = _torch.load + original_force_wo = os.environ.get("TORCH_FORCE_WEIGHTS_ONLY_LOAD") + original_force_no_wo = os.environ.get("TORCH_FORCE_NO_WEIGHTS_ONLY_LOAD") + + def _patched_load(*args, **kwargs): + kwargs["weights_only"] = False + return original_load(*args, **kwargs) + + _torch.load = _patched_load + try: + os.environ["TORCH_FORCE_WEIGHTS_ONLY_LOAD"] = "0" + os.environ["TORCH_FORCE_NO_WEIGHTS_ONLY_LOAD"] = "1" + return YOLO(str(weights_path)) + finally: + _torch.load = original_load + if original_force_wo is None: + os.environ.pop("TORCH_FORCE_WEIGHTS_ONLY_LOAD", None) + else: + os.environ["TORCH_FORCE_WEIGHTS_ONLY_LOAD"] = original_force_wo + if original_force_no_wo is None: + os.environ.pop("TORCH_FORCE_NO_WEIGHTS_ONLY_LOAD", None) + else: + os.environ["TORCH_FORCE_NO_WEIGHTS_ONLY_LOAD"] = original_force_no_wo + + +class MothObjectDetector_YOLO11m_Mothbot(ObjectDetector): + name = "Mothbot YOLO11m Creature Detector" + weights_path = ( + "https://object-arbutus.cloud.computecanada.ca/ami-models/" + "mothbot/detection/yolo11m_4500_imgsz1600_b1_2024-01-18.pt" + ) + description = ( + "Single-class 'creature' detector from Digital Naturalism " + "Laboratories' Mothbot project. YOLO11m-OBB, trained at " + "imgsz=1600, Jan 2024." + ) + # Overrides the base: we set the category map directly instead of + # hosting a one-entry labels.json on the object store. + category_map = {0: "creature"} + imgsz = 1600 + bbox_score_threshold = 0.25 + box_detections_per_img = 500 + + def get_labels(self, labels_path) -> dict[int, str]: + # The base class __init__ calls get_labels(labels_path) and assigns the + # return value to self.category_map, which would overwrite the class-level + # category_map with {} when labels_path is None. Return the class-level + # map directly so it survives the init cycle. + if labels_path: + return super().get_labels(labels_path) + return type(self).category_map + + def get_transforms(self): + # ultralytics handles letterboxing / normalization internally; just + # pass the PIL image through unchanged. + return lambda pil_image: pil_image + + def get_model(self): + logger.debug(f"Loading YOLO weights: {self.weights}") + model = _load_ultralytics_yolo(self.weights) + # ultralytics manages its own device placement via the device kwarg + # passed to .predict(), so we don't .to(self.device) here. + return model + + def get_dataloader(self): + """PIL images can't be stacked by default_collate, so we collate as + lists and let predict_batch hand a list of PIL images to ultralytics. + """ + logger.info( + f"Preparing {self.name} inference dataloader " + f"(batch_size={self.batch_size}, single={self.single})" + ) + + def collate_as_lists(batch): + ids = [b[0] for b in batch] + imgs = [b[1] for b in batch] + return ids, imgs + + dataloader_args = { + "num_workers": 0 if self.single else self.num_workers, + "persistent_workers": False if self.single else True, + "shuffle": False, + "pin_memory": False, + "batch_size": self.batch_size, + "collate_fn": collate_as_lists, + } + self.dataloader = torch.utils.data.DataLoader(self.dataset, **dataloader_args) + return self.dataloader + + def predict_batch(self, batch): + """Run YOLO inference. Accepts either: + + - list[PIL.Image] (from our ML-layer dataloader, which collates as lists) + - torch.Tensor of shape (B, C, H, W) (from the antenna REST dataloader, + which applies torchvision.transforms.ToTensor to each PIL image) + - list[torch.Tensor] of shape (C, H, W) (REST dataloader mixed-size fallback) + + For tensor inputs we convert back to numpy HWC uint8 so ultralytics + does its own letterboxing / normalization. The model was trained on + cv2.imread-loaded images (BGR); ultralytics does NOT convert numpy + inputs' channel order (see LoadPilAndNumpy._single_check docstring), + so we flip RGB to BGR here. Without this the detector emits large, + low-quality boxes because the color statistics don't match training. + """ + if isinstance(batch, torch.Tensor): + # (B, C, H, W) RGB in [0, 1] float -> list of (H, W, C) BGR uint8 + imgs = [_tensor_to_bgr_numpy(t) for t in batch] + elif isinstance(batch, list) and batch and isinstance(batch[0], torch.Tensor): + imgs = [_tensor_to_bgr_numpy(t) for t in batch] + else: + imgs = batch + return self.model.predict( + imgs, + imgsz=self.imgsz, + conf=self.bbox_score_threshold, + max_det=self.box_detections_per_img, + device=self.device, + verbose=False, + ) + + def post_process_single(self, result): + """Flatten one ultralytics Result into a list of detection records. + + Why the OBB to axis-aligned envelope: + YOLO11m-OBB outputs 4 rotated corner points per detection. Our + DetectionResponse schema carries a single axis-aligned bbox, and + the downstream InsectOrderClassifier reads an axis-aligned crop. + We therefore take the min/max envelope of the 4 corners as the + bbox. The rotation angle (cv2.minAreaRect convention, same as + Mothbot) is preserved separately so a future species classifier + can reuse Mothbot's rotated crop_rect() without re-running + detection. + + Confidence filtering already happened inside model.predict(conf=...), + so every record here is above bbox_score_threshold. + """ + detections = [] + if result.obb is None: + return detections + corners_batch = result.obb.xyxyxyxy.cpu().numpy() # (N, 4, 2) + scores = result.obb.conf.cpu().numpy() # (N,) + # orig_shape is (height, width); present on ultralytics Result objects. + image_shape = getattr(result, "orig_shape", None) + for i in range(len(corners_batch)): + det = _corners_to_yolo_detection( + corners_batch[i], float(scores[i]), image_shape=image_shape + ) + if det.x2 <= det.x1 or det.y2 <= det.y1: + logger.warning( + f"Skipping degenerate YOLO detection (zero-area envelope " + f"after clamp to image {image_shape}): " + f"x1={det.x1:.1f} y1={det.y1:.1f} x2={det.x2:.1f} y2={det.y2:.1f}" + ) + continue + detections.append(det) + return detections + + def save_results(self, item_ids, batch_output, *args, **kwargs): + """The ML-layer base class expects a save method. The API wrapper + overrides this, so the DB path is never hit when used via the API. + Provide a no-op that logs, for symmetry with the FasterRCNN class. + """ + logger.info( + f"{self.name} ML-layer save_results called with {len(item_ids)} items " + "(no-op; API wrapper handles persistence)" + ) diff --git a/trapdata/ml/models/tests/__init__.py b/trapdata/ml/models/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/trapdata/ml/models/tests/test_mothbot_yolo.py b/trapdata/ml/models/tests/test_mothbot_yolo.py new file mode 100644 index 00000000..e846df58 --- /dev/null +++ b/trapdata/ml/models/tests/test_mothbot_yolo.py @@ -0,0 +1,223 @@ +"""Unit tests for the Mothbot YOLO detector's post-processing helpers. + +These tests stay pure-CPU and don't load any model weights -- they only +exercise the coordinate math that converts YOLO's 4 rotated corner +points into the (axis-aligned-bbox + rotation + score) shape our API +consumes. The model-loading path is covered by the integration test. +""" + +import numpy as np +import torch + +from trapdata.ml.models.localization import ( + YoloDetection, + _corners_to_yolo_detection, + _tensor_to_bgr_numpy, +) + + +def test_corners_to_yolo_detection_axis_aligned_square(): + """A non-rotated square: envelope equals corners, rotation ~0 or ~90 (cv2 convention).""" + corners = np.array( + [ + [10, 10], + [20, 10], + [20, 20], + [10, 20], + ], + dtype=np.float32, + ) + det = _corners_to_yolo_detection(corners, score=0.9) + + assert isinstance(det, YoloDetection) + assert det.x1 == 10 and det.y1 == 10 + assert det.x2 == 20 and det.y2 == 20 + assert det.score == 0.9 + # cv2.minAreaRect returns angle in (-90, 0] for a non-rotated square; either + # 0 or -90 (or +90) are valid depending on corner ordering. Just assert the + # angle is a finite float in the expected range. + assert -90.0 <= det.rotation <= 90.0 + + +def test_corners_to_yolo_detection_rotated_rectangle(): + """A rectangle rotated ~45 degrees: envelope is larger than either side, rotation non-zero.""" + # 10x4 rectangle centered at (50, 50), rotated 45 degrees. + cx, cy = 50.0, 50.0 + half_w, half_h = 5.0, 2.0 + cos_a, sin_a = np.cos(np.pi / 4), np.sin(np.pi / 4) + + local = np.array( + [ + [-half_w, -half_h], + [+half_w, -half_h], + [+half_w, +half_h], + [-half_w, +half_h], + ], + dtype=np.float32, + ) + R = np.array([[cos_a, -sin_a], [sin_a, cos_a]], dtype=np.float32) + corners = (local @ R.T) + np.array([cx, cy], dtype=np.float32) + + det = _corners_to_yolo_detection(corners, score=0.77) + + # Envelope must contain the rotated corners + assert det.x1 <= corners[:, 0].min() + 1e-3 + assert det.y1 <= corners[:, 1].min() + 1e-3 + assert det.x2 >= corners[:, 0].max() - 1e-3 + assert det.y2 >= corners[:, 1].max() - 1e-3 + + # Envelope for a rotated thin rectangle is strictly larger than its short side + # (at 45 deg the envelope width = (half_w + half_h) * sqrt(2) ~ 9.9, > 2*half_h=4) + assert (det.x2 - det.x1) > 2 * half_h + + # Score passes through + assert det.score == 0.77 + + # Rotation is non-trivial for a visibly rotated rectangle + assert abs(det.rotation) > 1.0 + + +def test_yolo_detection_is_frozen_dataclass(): + """YoloDetection should be an immutable dataclass (design requirement).""" + import dataclasses + + assert dataclasses.is_dataclass(YoloDetection) + # frozen=True makes instances hashable + det = YoloDetection(x1=0, y1=0, x2=1, y2=1, rotation=0.0, score=0.5) + # Hash should not raise + hash(det) + + +def test_corners_to_yolo_detection_degenerate_flat_obb(): + """All corners on same y → y1==y2 (H=0). This is the raw math output; + callers are responsible for filtering such degenerate detections before + passing them to a classifier crop/resize step.""" + # A perfectly horizontal line: all 4 corners share y=50 + corners = np.array( + [[0, 50], [1045, 50], [1045, 50], [0, 50]], + dtype=np.float32, + ) + det = _corners_to_yolo_detection(corners, score=0.85) + assert det.y1 == det.y2, "Expected degenerate (H=0) detection" + assert det.x2 > det.x1, "Width should be non-zero" + + +def test_tensor_to_bgr_numpy_swaps_channel_order(): + """Antenna's REST dataloader feeds YOLO via ToTensor(), which produces RGB. + Ultralytics does NOT reorder numpy inputs; it was trained on BGR (cv2.imread). + Without the flip, detections are large / low-quality because color stats + don't match training.""" + # Pure-red RGB tensor: R=1.0, G=0, B=0 in (C, H, W) layout + rgb_tensor = torch.zeros((3, 4, 5)) + rgb_tensor[0] = 1.0 # R channel + + bgr_np = _tensor_to_bgr_numpy(rgb_tensor) + + assert bgr_np.shape == (4, 5, 3), f"Expected (H, W, C), got {bgr_np.shape}" + assert bgr_np.dtype == np.uint8 + # In BGR layout the red pixel lives in the last channel (index 2) + assert bgr_np[0, 0, 2] == 255, "Red channel should map to BGR index 2" + assert bgr_np[0, 0, 0] == 0, "Blue channel should be empty (was G=0 in RGB)" + assert bgr_np[0, 0, 1] == 0 + + +def test_corners_to_yolo_detection_clamps_negative_coords(): + """YOLO-OBB can emit corners with negative coords for detections near image + edges (observed live: y1=-274.39 on a 2464-tall image). Without clamping, + downstream PyTorch slicing treats negatives as end-relative indices and + produces an empty crop -- which then hard-fails the classifier's Resize.""" + corners = np.array( + [[-50, -274], [2800, -274], [2800, 936], [-50, 936]], + dtype=np.float32, + ) + det = _corners_to_yolo_detection(corners, score=0.9, image_shape=(2464, 3280)) + assert det.x1 == 0.0, f"x1 should clamp to 0, got {det.x1}" + assert det.y1 == 0.0, f"y1 should clamp to 0, got {det.y1}" + assert det.x2 == 2800.0 + assert det.y2 == 936.0 + + +def test_corners_to_yolo_detection_clamps_to_image_bounds(): + """Corners extending past the far edge are clamped to (width, height).""" + corners = np.array( + [[3500, 2000], [3500, 2600], [4000, 2600], [4000, 2000]], + dtype=np.float32, + ) + det = _corners_to_yolo_detection(corners, score=0.9, image_shape=(2464, 3280)) + assert det.x2 == 3280.0, f"x2 should clamp to width, got {det.x2}" + assert det.y2 == 2464.0, f"y2 should clamp to height, got {det.y2}" + assert det.x1 == 3280.0 # whole box lies past right edge + assert det.y1 == 2000.0 + + +def test_post_process_single_clamps_using_orig_shape(): + """post_process_single must pass result.orig_shape through to the clamp + so detections with negative corners become valid crops.""" + from unittest.mock import MagicMock + + from trapdata.ml.models.localization import MothObjectDetector_YOLO11m_Mothbot + + detector = MothObjectDetector_YOLO11m_Mothbot.__new__( + MothObjectDetector_YOLO11m_Mothbot + ) + + # Negative y1 (like we see on real Panama diopsis images with YOLO-OBB) + neg_y_corners = np.array( + [[1566, -274], [2793, -274], [2793, 936], [1566, 936]], dtype=np.float32 + ) + + mock_obb = MagicMock() + mock_obb.xyxyxyxy.cpu().numpy.return_value = np.stack([neg_y_corners]) + mock_obb.conf.cpu().numpy.return_value = np.array([0.9]) + + mock_result = MagicMock() + mock_result.obb = mock_obb + mock_result.orig_shape = (2464, 3280) # (H, W), matches ultralytics convention + + dets = detector.post_process_single(mock_result) + assert len(dets) == 1 + # y1 must be clamped to 0 -- otherwise worker's int() preserves the sign and + # PyTorch reads it as end-relative, yielding an empty crop. + assert dets[0].y1 >= 0 + assert dets[0].x1 >= 0 + assert dets[0].y2 <= 2464 + assert dets[0].x2 <= 3280 + + +def test_post_process_single_filters_degenerate_detections(): + """post_process_single must drop zero-height or zero-width detections so + that the downstream Resize transform never receives a 0-dimension crop.""" + from unittest.mock import MagicMock + + from trapdata.ml.models.localization import MothObjectDetector_YOLO11m_Mothbot + + # Minimal detector instance (no model loaded, no weights needed) + detector = MothObjectDetector_YOLO11m_Mothbot.__new__( + MothObjectDetector_YOLO11m_Mothbot + ) + + # Build a mock ultralytics Result with two detections: + # - one valid (10x10 box) + # - one degenerate (H=0) + valid_corners = np.array([[0, 0], [10, 0], [10, 10], [0, 10]], dtype=np.float32) + flat_corners = np.array( + [[0, 50], [1045, 50], [1045, 50], [0, 50]], dtype=np.float32 + ) + + mock_obb = MagicMock() + mock_obb.xyxyxyxy.cpu().numpy.return_value = np.stack( + [valid_corners, flat_corners] + ) # shape (2, 4, 2) + mock_obb.conf.cpu().numpy.return_value = np.array([0.9, 0.85]) + + mock_result = MagicMock() + mock_result.obb = mock_obb + mock_result.orig_shape = (100, 1100) # (H, W) covers both test detections + + dets = detector.post_process_single(mock_result) + + assert ( + len(dets) == 1 + ), f"Expected 1 detection (degenerate filtered), got {len(dets)}: {dets}" + assert dets[0].x2 - dets[0].x1 > 0 + assert dets[0].y2 - dets[0].y1 > 0 diff --git a/uv.lock b/uv.lock index aaf96887..a6498f2c 100644 --- a/uv.lock +++ b/uv.lock @@ -8,7 +8,8 @@ resolution-markers = [ "python_full_version == '3.11.*' and sys_platform == 'emscripten'", "python_full_version >= '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version < '3.11'", + "python_full_version < '3.11' and sys_platform == 'win32'", + "python_full_version < '3.11' and sys_platform != 'win32'", ] [[package]] @@ -272,6 +273,103 @@ 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 = "contourpy" +version = "1.3.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11' and sys_platform == 'win32'", + "python_full_version < '3.11' and sys_platform != 'win32'", +] +dependencies = [ + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, +] +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/33/05/b26e3c6ecc05f349ee0013f0bb850a761016d89cec528a98193a48c34033/contourpy-1.3.2-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:fd93cc7f3139b6dd7aab2f26a90dde0aa9fc264dbf70f6740d498a70b860b82c", size = 265681, upload-time = "2025-04-15T17:44:59.314Z" }, + { url = "https://files.pythonhosted.org/packages/2b/25/ac07d6ad12affa7d1ffed11b77417d0a6308170f44ff20fa1d5aa6333f03/contourpy-1.3.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:107ba8a6a7eec58bb475329e6d3b95deba9440667c4d62b9b6063942b61d7f16", size = 315101, upload-time = "2025-04-15T17:45:04.165Z" }, + { url = "https://files.pythonhosted.org/packages/8f/4d/5bb3192bbe9d3f27e3061a6a8e7733c9120e203cb8515767d30973f71030/contourpy-1.3.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:ded1706ed0c1049224531b81128efbd5084598f18d8a2d9efae833edbd2b40ad", size = 220599, upload-time = "2025-04-15T17:45:08.456Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c0/91f1215d0d9f9f343e4773ba6c9b89e8c0cc7a64a6263f21139da639d848/contourpy-1.3.2-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:5f5964cdad279256c084b69c3f412b7801e15356b16efa9d78aa974041903da0", size = 266807, upload-time = "2025-04-15T17:45:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/d4/79/6be7e90c955c0487e7712660d6cead01fa17bff98e0ea275737cc2bc8e71/contourpy-1.3.2-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49b65a95d642d4efa8f64ba12558fcb83407e58a2dfba9d796d77b63ccfcaff5", size = 318729, upload-time = "2025-04-15T17:45:20.166Z" }, + { url = "https://files.pythonhosted.org/packages/87/68/7f46fb537958e87427d98a4074bcde4b67a70b04900cfc5ce29bc2f556c1/contourpy-1.3.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:8c5acb8dddb0752bf252e01a3035b21443158910ac16a3b0d20e7fed7d534ce5", size = 221791, upload-time = "2025-04-15T17:45:24.794Z" }, +] + +[[package]] +name = "contourpy" +version = "1.3.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'win32'", + "python_full_version >= '3.12' and sys_platform == 'emscripten'", + "python_full_version == '3.11.*' and sys_platform == 'emscripten'", + "python_full_version >= '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +dependencies = [ + { name = "numpy", version = "2.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174, upload-time = "2025-07-26T12:03:12.549Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/2e/c4390a31919d8a78b90e8ecf87cd4b4c4f05a5b48d05ec17db8e5404c6f4/contourpy-1.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:709a48ef9a690e1343202916450bc48b9e51c049b089c7f79a267b46cffcdaa1", size = 288773, upload-time = "2025-07-26T12:01:02.277Z" }, + { url = "https://files.pythonhosted.org/packages/0d/44/c4b0b6095fef4dc9c420e041799591e3b63e9619e3044f7f4f6c21c0ab24/contourpy-1.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:23416f38bfd74d5d28ab8429cc4d63fa67d5068bd711a85edb1c3fb0c3e2f381", size = 270149, upload-time = "2025-07-26T12:01:04.072Z" }, + { url = "https://files.pythonhosted.org/packages/30/2e/dd4ced42fefac8470661d7cb7e264808425e6c5d56d175291e93890cce09/contourpy-1.3.3-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:929ddf8c4c7f348e4c0a5a3a714b5c8542ffaa8c22954862a46ca1813b667ee7", size = 329222, upload-time = "2025-07-26T12:01:05.688Z" }, + { url = "https://files.pythonhosted.org/packages/f2/74/cc6ec2548e3d276c71389ea4802a774b7aa3558223b7bade3f25787fafc2/contourpy-1.3.3-cp311-cp311-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9e999574eddae35f1312c2b4b717b7885d4edd6cb46700e04f7f02db454e67c1", size = 377234, upload-time = "2025-07-26T12:01:07.054Z" }, + { url = "https://files.pythonhosted.org/packages/03/b3/64ef723029f917410f75c09da54254c5f9ea90ef89b143ccadb09df14c15/contourpy-1.3.3-cp311-cp311-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0bf67e0e3f482cb69779dd3061b534eb35ac9b17f163d851e2a547d56dba0a3a", size = 380555, upload-time = "2025-07-26T12:01:08.801Z" }, + { url = "https://files.pythonhosted.org/packages/5f/4b/6157f24ca425b89fe2eb7e7be642375711ab671135be21e6faa100f7448c/contourpy-1.3.3-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:51e79c1f7470158e838808d4a996fa9bac72c498e93d8ebe5119bc1e6becb0db", size = 355238, upload-time = "2025-07-26T12:01:10.319Z" }, + { url = "https://files.pythonhosted.org/packages/98/56/f914f0dd678480708a04cfd2206e7c382533249bc5001eb9f58aa693e200/contourpy-1.3.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:598c3aaece21c503615fd59c92a3598b428b2f01bfb4b8ca9c4edeecc2438620", size = 1326218, upload-time = "2025-07-26T12:01:12.659Z" }, + { url = "https://files.pythonhosted.org/packages/fb/d7/4a972334a0c971acd5172389671113ae82aa7527073980c38d5868ff1161/contourpy-1.3.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:322ab1c99b008dad206d406bb61d014cf0174df491ae9d9d0fac6a6fda4f977f", size = 1392867, upload-time = "2025-07-26T12:01:15.533Z" }, + { url = "https://files.pythonhosted.org/packages/75/3e/f2cc6cd56dc8cff46b1a56232eabc6feea52720083ea71ab15523daab796/contourpy-1.3.3-cp311-cp311-win32.whl", hash = "sha256:fd907ae12cd483cd83e414b12941c632a969171bf90fc937d0c9f268a31cafff", size = 183677, upload-time = "2025-07-26T12:01:17.088Z" }, + { url = "https://files.pythonhosted.org/packages/98/4b/9bd370b004b5c9d8045c6c33cf65bae018b27aca550a3f657cdc99acdbd8/contourpy-1.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:3519428f6be58431c56581f1694ba8e50626f2dd550af225f82fb5f5814d2a42", size = 225234, upload-time = "2025-07-26T12:01:18.256Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b6/71771e02c2e004450c12b1120a5f488cad2e4d5b590b1af8bad060360fe4/contourpy-1.3.3-cp311-cp311-win_arm64.whl", hash = "sha256:15ff10bfada4bf92ec8b31c62bf7c1834c244019b4a33095a68000d7075df470", size = 193123, upload-time = "2025-07-26T12:01:19.848Z" }, + { url = "https://files.pythonhosted.org/packages/be/45/adfee365d9ea3d853550b2e735f9d66366701c65db7855cd07621732ccfc/contourpy-1.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b08a32ea2f8e42cf1d4be3169a98dd4be32bafe4f22b6c4cb4ba810fa9e5d2cb", size = 293419, upload-time = "2025-07-26T12:01:21.16Z" }, + { url = "https://files.pythonhosted.org/packages/53/3e/405b59cfa13021a56bba395a6b3aca8cec012b45bf177b0eaf7a202cde2c/contourpy-1.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:556dba8fb6f5d8742f2923fe9457dbdd51e1049c4a43fd3986a0b14a1d815fc6", size = 273979, upload-time = "2025-07-26T12:01:22.448Z" }, + { url = "https://files.pythonhosted.org/packages/d4/1c/a12359b9b2ca3a845e8f7f9ac08bdf776114eb931392fcad91743e2ea17b/contourpy-1.3.3-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92d9abc807cf7d0e047b95ca5d957cf4792fcd04e920ca70d48add15c1a90ea7", size = 332653, upload-time = "2025-07-26T12:01:24.155Z" }, + { url = "https://files.pythonhosted.org/packages/63/12/897aeebfb475b7748ea67b61e045accdfcf0d971f8a588b67108ed7f5512/contourpy-1.3.3-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2e8faa0ed68cb29af51edd8e24798bb661eac3bd9f65420c1887b6ca89987c8", size = 379536, upload-time = "2025-07-26T12:01:25.91Z" }, + { url = "https://files.pythonhosted.org/packages/43/8a/a8c584b82deb248930ce069e71576fc09bd7174bbd35183b7943fb1064fd/contourpy-1.3.3-cp312-cp312-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:626d60935cf668e70a5ce6ff184fd713e9683fb458898e4249b63be9e28286ea", size = 384397, upload-time = "2025-07-26T12:01:27.152Z" }, + { url = "https://files.pythonhosted.org/packages/cc/8f/ec6289987824b29529d0dfda0d74a07cec60e54b9c92f3c9da4c0ac732de/contourpy-1.3.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d00e655fcef08aba35ec9610536bfe90267d7ab5ba944f7032549c55a146da1", size = 362601, upload-time = "2025-07-26T12:01:28.808Z" }, + { url = "https://files.pythonhosted.org/packages/05/0a/a3fe3be3ee2dceb3e615ebb4df97ae6f3828aa915d3e10549ce016302bd1/contourpy-1.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:451e71b5a7d597379ef572de31eeb909a87246974d960049a9848c3bc6c41bf7", size = 1331288, upload-time = "2025-07-26T12:01:31.198Z" }, + { url = "https://files.pythonhosted.org/packages/33/1d/acad9bd4e97f13f3e2b18a3977fe1b4a37ecf3d38d815333980c6c72e963/contourpy-1.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:459c1f020cd59fcfe6650180678a9993932d80d44ccde1fa1868977438f0b411", size = 1403386, upload-time = "2025-07-26T12:01:33.947Z" }, + { url = "https://files.pythonhosted.org/packages/cf/8f/5847f44a7fddf859704217a99a23a4f6417b10e5ab1256a179264561540e/contourpy-1.3.3-cp312-cp312-win32.whl", hash = "sha256:023b44101dfe49d7d53932be418477dba359649246075c996866106da069af69", size = 185018, upload-time = "2025-07-26T12:01:35.64Z" }, + { url = "https://files.pythonhosted.org/packages/19/e8/6026ed58a64563186a9ee3f29f41261fd1828f527dd93d33b60feca63352/contourpy-1.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:8153b8bfc11e1e4d75bcb0bff1db232f9e10b274e0929de9d608027e0d34ff8b", size = 226567, upload-time = "2025-07-26T12:01:36.804Z" }, + { url = "https://files.pythonhosted.org/packages/d1/e2/f05240d2c39a1ed228d8328a78b6f44cd695f7ef47beb3e684cf93604f86/contourpy-1.3.3-cp312-cp312-win_arm64.whl", hash = "sha256:07ce5ed73ecdc4a03ffe3e1b3e3c1166db35ae7584be76f65dbbe28a7791b0cc", size = 193655, upload-time = "2025-07-26T12:01:37.999Z" }, + { url = "https://files.pythonhosted.org/packages/a5/29/8dcfe16f0107943fa92388c23f6e05cff0ba58058c4c95b00280d4c75a14/contourpy-1.3.3-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:cd5dfcaeb10f7b7f9dc8941717c6c2ade08f587be2226222c12b25f0483ed497", size = 278809, upload-time = "2025-07-26T12:02:52.74Z" }, + { url = "https://files.pythonhosted.org/packages/85/a9/8b37ef4f7dafeb335daee3c8254645ef5725be4d9c6aa70b50ec46ef2f7e/contourpy-1.3.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:0c1fc238306b35f246d61a1d416a627348b5cf0648648a031e14bb8705fcdfe8", size = 261593, upload-time = "2025-07-26T12:02:54.037Z" }, + { url = "https://files.pythonhosted.org/packages/0a/59/ebfb8c677c75605cc27f7122c90313fd2f375ff3c8d19a1694bda74aaa63/contourpy-1.3.3-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70f9aad7de812d6541d29d2bbf8feb22ff7e1c299523db288004e3157ff4674e", size = 302202, upload-time = "2025-07-26T12:02:55.947Z" }, + { url = "https://files.pythonhosted.org/packages/3c/37/21972a15834d90bfbfb009b9d004779bd5a07a0ec0234e5ba8f64d5736f4/contourpy-1.3.3-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5ed3657edf08512fc3fe81b510e35c2012fbd3081d2e26160f27ca28affec989", size = 329207, upload-time = "2025-07-26T12:02:57.468Z" }, + { url = "https://files.pythonhosted.org/packages/0c/58/bd257695f39d05594ca4ad60df5bcb7e32247f9951fd09a9b8edb82d1daa/contourpy-1.3.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:3d1a3799d62d45c18bafd41c5fa05120b96a28079f2393af559b843d1a966a77", size = 225315, upload-time = "2025-07-26T12:02:58.801Z" }, +] + [[package]] name = "coverage" version = "7.13.4" @@ -351,6 +449,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b8/5e/db279a3bfbd18d59d0598922a3b3c1454908d0969e8372260afec9736376/cuda_pathfinder-1.3.4-py3-none-any.whl", hash = "sha256:fb983f6e0d43af27ef486e14d5989b5f904ef45cedf40538bfdcbffa6bb01fb2", size = 30878, upload-time = "2026-02-11T18:50:31.008Z" }, ] +[[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 = "decorator" version = "5.2.1" @@ -447,6 +554,39 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9f/56/13ab06b4f93ca7cac71078fbe37fcea175d3216f31f85c3168a6bbd0bb9a/flake8-7.3.0-py2.py3-none-any.whl", hash = "sha256:b9696257b9ce8beb888cdbe31cf885c90d31928fe202be0889a7cdafad32f01e", size = 57922, upload-time = "2025-06-20T19:31:34.425Z" }, ] +[[package]] +name = "fonttools" +version = "4.62.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/08/7012b00a9a5874311b639c3920270c36ee0c445b69d9989a85e5c92ebcb0/fonttools-4.62.1.tar.gz", hash = "sha256:e54c75fd6041f1122476776880f7c3c3295ffa31962dc6ebe2543c00dca58b5d", size = 3580737, upload-time = "2026-03-13T13:54:25.52Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/ff/532ed43808b469c807e8cb6b21358da3fe6fd51486b3a8c93db0bb5d957f/fonttools-4.62.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ad5cca75776cd453b1b035b530e943334957ae152a36a88a320e779d61fc980c", size = 2873740, upload-time = "2026-03-13T13:52:11.822Z" }, + { url = "https://files.pythonhosted.org/packages/85/e4/2318d2b430562da7227010fb2bb029d2fa54d7b46443ae8942bab224e2a0/fonttools-4.62.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0b3ae47e8636156a9accff64c02c0924cbebad62854c4a6dbdc110cd5b4b341a", size = 2417649, upload-time = "2026-03-13T13:52:14.605Z" }, + { url = "https://files.pythonhosted.org/packages/4c/28/40f15523b5188598018e7956899fed94eb7debec89e2dd70cb4a8df90492/fonttools-4.62.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c9b9e288b4da2f64fd6180644221749de651703e8d0c16bd4b719533a3a7d6e3", size = 4935213, upload-time = "2026-03-13T13:52:17.399Z" }, + { url = "https://files.pythonhosted.org/packages/42/09/7dbe3d7023f57d9b580cfa832109d521988112fd59dddfda3fddda8218f9/fonttools-4.62.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7bca7a1c1faf235ffe25d4f2e555246b4750220b38de8261d94ebc5ce8a23c23", size = 4892374, upload-time = "2026-03-13T13:52:20.175Z" }, + { url = "https://files.pythonhosted.org/packages/d1/2d/84509a2e32cb925371560ef5431365d8da2183c11d98e5b4b8b4e42426a5/fonttools-4.62.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b4e0fcf265ad26e487c56cb12a42dffe7162de708762db951e1b3f755319507d", size = 4911856, upload-time = "2026-03-13T13:52:22.777Z" }, + { url = "https://files.pythonhosted.org/packages/a5/80/df28131379eed93d9e6e6fccd3bf6e3d077bebbfe98cc83f21bbcd83ed02/fonttools-4.62.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2d850f66830a27b0d498ee05adb13a3781637b1826982cd7e2b3789ef0cc71ae", size = 5031712, upload-time = "2026-03-13T13:52:25.14Z" }, + { url = "https://files.pythonhosted.org/packages/3d/03/3c8f09aad64230cd6d921ae7a19f9603c36f70930b00459f112706f6769a/fonttools-4.62.1-cp310-cp310-win32.whl", hash = "sha256:486f32c8047ccd05652aba17e4a8819a3a9d78570eb8a0e3b4503142947880ed", size = 1507878, upload-time = "2026-03-13T13:52:28.149Z" }, + { url = "https://files.pythonhosted.org/packages/dd/ec/f53f626f8f3e89f4cadd8fc08f3452c8fd182c951ad5caa35efac22b29ab/fonttools-4.62.1-cp310-cp310-win_amd64.whl", hash = "sha256:5a648bde915fba9da05ae98856987ca91ba832949a9e2888b48c47ef8b96c5a9", size = 1556766, upload-time = "2026-03-13T13:52:30.814Z" }, + { url = "https://files.pythonhosted.org/packages/88/39/23ff32561ec8d45a4d48578b4d241369d9270dc50926c017570e60893701/fonttools-4.62.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:40975849bac44fb0b9253d77420c6d8b523ac4dcdcefeff6e4d706838a5b80f7", size = 2871039, upload-time = "2026-03-13T13:52:33.127Z" }, + { url = "https://files.pythonhosted.org/packages/24/7f/66d3f8a9338a9b67fe6e1739f47e1cd5cee78bd3bc1206ef9b0b982289a5/fonttools-4.62.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9dde91633f77fa576879a0c76b1d89de373cae751a98ddf0109d54e173b40f14", size = 2416346, upload-time = "2026-03-13T13:52:35.676Z" }, + { url = "https://files.pythonhosted.org/packages/aa/53/5276ceba7bff95da7793a07c5284e1da901cf00341ce5e2f3273056c0cca/fonttools-4.62.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6acb4109f8bee00fec985c8c7afb02299e35e9c94b57287f3ea542f28bd0b0a7", size = 5100897, upload-time = "2026-03-13T13:52:38.102Z" }, + { url = "https://files.pythonhosted.org/packages/cc/a1/40a5c4d8e28b0851d53a8eeeb46fbd73c325a2a9a165f290a5ed90e6c597/fonttools-4.62.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1c5c25671ce8805e0d080e2ffdeca7f1e86778c5cbfbeae86d7f866d8830517b", size = 5071078, upload-time = "2026-03-13T13:52:41.305Z" }, + { url = "https://files.pythonhosted.org/packages/e3/be/d378fca4c65ea1956fee6d90ace6e861776809cbbc5af22388a090c3c092/fonttools-4.62.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a5d8825e1140f04e6c99bb7d37a9e31c172f3bc208afbe02175339e699c710e1", size = 5076908, upload-time = "2026-03-13T13:52:44.122Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d9/ae6a1d0693a4185a84605679c8a1f719a55df87b9c6e8e817bfdd9ef5936/fonttools-4.62.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:268abb1cb221e66c014acc234e872b7870d8b5d4657a83a8f4205094c32d2416", size = 5202275, upload-time = "2026-03-13T13:52:46.591Z" }, + { url = "https://files.pythonhosted.org/packages/54/6c/af95d9c4efb15cabff22642b608342f2bd67137eea6107202d91b5b03184/fonttools-4.62.1-cp311-cp311-win32.whl", hash = "sha256:942b03094d7edbb99bdf1ae7e9090898cad7bf9030b3d21f33d7072dbcb51a53", size = 2293075, upload-time = "2026-03-13T13:52:48.711Z" }, + { url = "https://files.pythonhosted.org/packages/d3/97/bf54c5b3f2be34e1f143e6db838dfdc54f2ffa3e68c738934c82f3b2a08d/fonttools-4.62.1-cp311-cp311-win_amd64.whl", hash = "sha256:e8514f4924375f77084e81467e63238b095abda5107620f49421c368a6017ed2", size = 2344593, upload-time = "2026-03-13T13:52:50.725Z" }, + { url = "https://files.pythonhosted.org/packages/47/d4/dbacced3953544b9a93088cc10ef2b596d348c983d5c67a404fa41ec51ba/fonttools-4.62.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:90365821debbd7db678809c7491ca4acd1e0779b9624cdc6ddaf1f31992bf974", size = 2870219, upload-time = "2026-03-13T13:52:53.664Z" }, + { url = "https://files.pythonhosted.org/packages/66/9e/a769c8e99b81e5a87ab7e5e7236684de4e96246aae17274e5347d11ebd78/fonttools-4.62.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:12859ff0b47dd20f110804c3e0d0970f7b832f561630cd879969011541a464a9", size = 2414891, upload-time = "2026-03-13T13:52:56.493Z" }, + { url = "https://files.pythonhosted.org/packages/69/64/f19a9e3911968c37e1e620e14dfc5778299e1474f72f4e57c5ec771d9489/fonttools-4.62.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c125ffa00c3d9003cdaaf7f2c79e6e535628093e14b5de1dccb08859b680936", size = 5033197, upload-time = "2026-03-13T13:52:59.179Z" }, + { url = "https://files.pythonhosted.org/packages/9b/8a/99c8b3c3888c5c474c08dbfd7c8899786de9604b727fcefb055b42c84bba/fonttools-4.62.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:149f7d84afca659d1a97e39a4778794a2f83bf344c5ee5134e09995086cc2392", size = 4988768, upload-time = "2026-03-13T13:53:02.761Z" }, + { url = "https://files.pythonhosted.org/packages/d1/c6/0f904540d3e6ab463c1243a0d803504826a11604c72dd58c2949796a1762/fonttools-4.62.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0aa72c43a601cfa9273bb1ae0518f1acadc01ee181a6fc60cd758d7fdadffc04", size = 4971512, upload-time = "2026-03-13T13:53:05.678Z" }, + { url = "https://files.pythonhosted.org/packages/29/0b/5cbef6588dc9bd6b5c9ad6a4d5a8ca384d0cea089da31711bbeb4f9654a6/fonttools-4.62.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:19177c8d96c7c36359266e571c5173bcee9157b59cfc8cb0153c5673dc5a3a7d", size = 5122723, upload-time = "2026-03-13T13:53:08.662Z" }, + { url = "https://files.pythonhosted.org/packages/4a/47/b3a5342d381595ef439adec67848bed561ab7fdb1019fa522e82101b7d9c/fonttools-4.62.1-cp312-cp312-win32.whl", hash = "sha256:a24decd24d60744ee8b4679d38e88b8303d86772053afc29b19d23bb8207803c", size = 2281278, upload-time = "2026-03-13T13:53:10.998Z" }, + { url = "https://files.pythonhosted.org/packages/28/b1/0c2ab56a16f409c6c8a68816e6af707827ad5d629634691ff60a52879792/fonttools-4.62.1-cp312-cp312-win_amd64.whl", hash = "sha256:9e7863e10b3de72376280b515d35b14f5eeed639d1aa7824f4cf06779ec65e42", size = 2331414, upload-time = "2026-03-13T13:53:13.992Z" }, + { url = "https://files.pythonhosted.org/packages/fd/ba/56147c165442cc5ba7e82ecf301c9a68353cede498185869e6e02b4c264f/fonttools-4.62.1-py3-none-any.whl", hash = "sha256:7487782e2113861f4ddcc07c3436450659e3caa5e470b27dc2177cade2d8e7fd", size = 1152647, upload-time = "2026-03-13T13:54:22.735Z" }, +] + [[package]] name = "fsspec" version = "2026.2.0" @@ -661,7 +801,8 @@ name = "ipython" version = "8.38.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version < '3.11'", + "python_full_version < '3.11' and sys_platform == 'win32'", + "python_full_version < '3.11' and sys_platform != 'win32'", ] dependencies = [ { name = "colorama", marker = "python_full_version < '3.11' and sys_platform == 'win32'" }, @@ -848,6 +989,71 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6a/55/cd1555bde62f809219cbc5d8a0836b0293399da2f4ba4e8ee84b6a7cc393/Kivy_Garden-0.1.5-py3-none-any.whl", hash = "sha256:ef50f44b96358cf10ac5665f27a4751bb34ef54051c54b93af891f80afe42929", size = 4623, upload-time = "2022-03-23T23:25:33.752Z" }, ] +[[package]] +name = "kiwisolver" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/67/9c61eccb13f0bdca9307614e782fec49ffdde0f7a2314935d489fa93cd9c/kiwisolver-1.5.0.tar.gz", hash = "sha256:d4193f3d9dc3f6f79aaed0e5637f45d98850ebf01f7ca20e69457f3e8946b66a", size = 103482, upload-time = "2026-03-09T13:15:53.382Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ac/f8/06549565caa026e540b7e7bab5c5a90eb7ca986015f4c48dace243cd24d9/kiwisolver-1.5.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:32cc0a5365239a6ea0c6ed461e8838d053b57e397443c0ca894dcc8e388d4374", size = 122802, upload-time = "2026-03-09T13:12:37.515Z" }, + { url = "https://files.pythonhosted.org/packages/84/eb/8476a0818850c563ff343ea7c9c05dcdcbd689a38e01aa31657df01f91fa/kiwisolver-1.5.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:cc0b66c1eec9021353a4b4483afb12dfd50e3669ffbb9152d6842eb34c7e29fd", size = 66216, upload-time = "2026-03-09T13:12:38.812Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c4/f9c8a6b4c21aed4198566e45923512986d6cef530e7263b3a5f823546561/kiwisolver-1.5.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:86e0287879f75621ae85197b0877ed2f8b7aa57b511c7331dce2eb6f4de7d476", size = 63917, upload-time = "2026-03-09T13:12:40.053Z" }, + { url = "https://files.pythonhosted.org/packages/f1/0e/ba4ae25d03722f64de8b2c13e80d82ab537a06b30fc7065183c6439357e3/kiwisolver-1.5.0-cp310-cp310-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:62f59da443c4f4849f73a51a193b1d9d258dcad0c41bc4d1b8fb2bcc04bfeb22", size = 1628776, upload-time = "2026-03-09T13:12:41.976Z" }, + { url = "https://files.pythonhosted.org/packages/8a/e4/3f43a011bc8a0860d1c96f84d32fa87439d3feedf66e672fef03bf5e8bac/kiwisolver-1.5.0-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9190426b7aa26c5229501fa297b8d0653cfd3f5a36f7990c264e157cbf886b3b", size = 1228164, upload-time = "2026-03-09T13:12:44.002Z" }, + { url = "https://files.pythonhosted.org/packages/4b/34/3a901559a1e0c218404f9a61a93be82d45cb8f44453ba43088644980f033/kiwisolver-1.5.0-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c8277104ded0a51e699c8c3aff63ce2c56d4ed5519a5f73e0fd7057f959a2b9e", size = 1246656, upload-time = "2026-03-09T13:12:45.557Z" }, + { url = "https://files.pythonhosted.org/packages/87/9e/f78c466ea20527822b95ad38f141f2de1dcd7f23fb8716b002b0d91bbe59/kiwisolver-1.5.0-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8f9baf6f0a6e7571c45c8863010b45e837c3ee1c2c77fcd6ef423be91b21fedb", size = 1295562, upload-time = "2026-03-09T13:12:47.562Z" }, + { url = "https://files.pythonhosted.org/packages/0a/66/fd0e4a612e3a286c24e6d6f3a5428d11258ed1909bc530ba3b59807fd980/kiwisolver-1.5.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cff8e5383db4989311f99e814feeb90c4723eb4edca425b9d5d9c3fefcdd9537", size = 2178473, upload-time = "2026-03-09T13:12:50.254Z" }, + { url = "https://files.pythonhosted.org/packages/dc/8e/6cac929e0049539e5ee25c1ee937556f379ba5204840d03008363ced662d/kiwisolver-1.5.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:ebae99ed6764f2b5771c522477b311be313e8841d2e0376db2b10922daebbba4", size = 2274035, upload-time = "2026-03-09T13:12:51.785Z" }, + { url = "https://files.pythonhosted.org/packages/ca/d3/9d0c18f1b52ea8074b792452cf17f1f5a56bd0302a85191f405cfbf9da16/kiwisolver-1.5.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:d5cd5189fc2b6a538b75ae45433140c4823463918f7b1617c31e68b085c0022c", size = 2443217, upload-time = "2026-03-09T13:12:53.329Z" }, + { url = "https://files.pythonhosted.org/packages/45/2a/6e19368803a038b2a90857bf4ee9e3c7b667216d045866bf22d3439fd75e/kiwisolver-1.5.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f42c23db5d1521218a3276bb08666dcb662896a0be7347cba864eca45ff64ede", size = 2249196, upload-time = "2026-03-09T13:12:55.057Z" }, + { url = "https://files.pythonhosted.org/packages/75/2b/3f641dfcbe72e222175d626bacf2f72c3b34312afec949dd1c50afa400f5/kiwisolver-1.5.0-cp310-cp310-win_amd64.whl", hash = "sha256:94eff26096eb5395136634622515b234ecb6c9979824c1f5004c6e3c3c85ccd2", size = 73389, upload-time = "2026-03-09T13:12:56.496Z" }, + { url = "https://files.pythonhosted.org/packages/da/88/299b137b9e0025d8982e03d2d52c123b0a2b159e84b0ef1501ef446339cf/kiwisolver-1.5.0-cp310-cp310-win_arm64.whl", hash = "sha256:dd952e03bfbb096cfe2dd35cd9e00f269969b67536cb4370994afc20ff2d0875", size = 64782, upload-time = "2026-03-09T13:12:57.609Z" }, + { url = "https://files.pythonhosted.org/packages/12/dd/a495a9c104be1c476f0386e714252caf2b7eca883915422a64c50b88c6f5/kiwisolver-1.5.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9eed0f7edbb274413b6ee781cca50541c8c0facd3d6fd289779e494340a2b85c", size = 122798, upload-time = "2026-03-09T13:12:58.963Z" }, + { url = "https://files.pythonhosted.org/packages/11/60/37b4047a2af0cf5ef6d8b4b26e91829ae6fc6a2d1f74524bcb0e7cd28a32/kiwisolver-1.5.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3c4923e404d6bcd91b6779c009542e5647fef32e4a5d75e115e3bbac6f2335eb", size = 66216, upload-time = "2026-03-09T13:13:00.155Z" }, + { url = "https://files.pythonhosted.org/packages/0a/aa/510dc933d87767584abfe03efa445889996c70c2990f6f87c3ebaa0a18c5/kiwisolver-1.5.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0df54df7e686afa55e6f21fb86195224a6d9beb71d637e8d7920c95cf0f89aac", size = 63911, upload-time = "2026-03-09T13:13:01.671Z" }, + { url = "https://files.pythonhosted.org/packages/80/46/bddc13df6c2a40741e0cc7865bb1c9ed4796b6760bd04ce5fae3928ef917/kiwisolver-1.5.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2517e24d7315eb51c10664cdb865195df38ab74456c677df67bb47f12d088a27", size = 1438209, upload-time = "2026-03-09T13:13:03.385Z" }, + { url = "https://files.pythonhosted.org/packages/fd/d6/76621246f5165e5372f02f5e6f3f48ea336a8f9e96e43997d45b240ed8cd/kiwisolver-1.5.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff710414307fefa903e0d9bdf300972f892c23477829f49504e59834f4195398", size = 1248888, upload-time = "2026-03-09T13:13:05.231Z" }, + { url = "https://files.pythonhosted.org/packages/b2/c1/31559ec6fb39a5b48035ce29bb63ade628f321785f38c384dee3e2c08bc1/kiwisolver-1.5.0-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6176c1811d9d5a04fa391c490cc44f451e240697a16977f11c6f722efb9041db", size = 1266304, upload-time = "2026-03-09T13:13:06.743Z" }, + { url = "https://files.pythonhosted.org/packages/5e/ef/1cb8276f2d29cc6a41e0a042f27946ca347d3a4a75acf85d0a16aa6dcc82/kiwisolver-1.5.0-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50847dca5d197fcbd389c805aa1a1cf32f25d2e7273dc47ab181a517666b68cc", size = 1319650, upload-time = "2026-03-09T13:13:08.607Z" }, + { url = "https://files.pythonhosted.org/packages/4c/e4/5ba3cecd7ce6236ae4a80f67e5d5531287337d0e1f076ca87a5abe4cd5d0/kiwisolver-1.5.0-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:01808c6d15f4c3e8559595d6d1fe6411c68e4a3822b4b9972b44473b24f4e679", size = 970949, upload-time = "2026-03-09T13:13:10.299Z" }, + { url = "https://files.pythonhosted.org/packages/5a/69/dc61f7ae9a2f071f26004ced87f078235b5507ab6e5acd78f40365655034/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f1f9f4121ec58628c96baa3de1a55a4e3a333c5102c8e94b64e23bf7b2083309", size = 2199125, upload-time = "2026-03-09T13:13:11.841Z" }, + { url = "https://files.pythonhosted.org/packages/e5/7b/abbe0f1b5afa85f8d084b73e90e5f801c0939eba16ac2e49af7c61a6c28d/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:b7d335370ae48a780c6e6a6bbfa97342f563744c39c35562f3f367665f5c1de2", size = 2293783, upload-time = "2026-03-09T13:13:14.399Z" }, + { url = "https://files.pythonhosted.org/packages/8a/80/5908ae149d96d81580d604c7f8aefd0e98f4fd728cf172f477e9f2a81744/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:800ee55980c18545af444d93fdd60c56b580db5cc54867d8cbf8a1dc0829938c", size = 1960726, upload-time = "2026-03-09T13:13:16.047Z" }, + { url = "https://files.pythonhosted.org/packages/84/08/a78cb776f8c085b7143142ce479859cfec086bd09ee638a317040b6ef420/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:c438f6ca858697c9ab67eb28246c92508af972e114cac34e57a6d4ba17a3ac08", size = 2464738, upload-time = "2026-03-09T13:13:17.897Z" }, + { url = "https://files.pythonhosted.org/packages/b1/e1/65584da5356ed6cb12c63791a10b208860ac40a83de165cb6a6751a686e3/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:8c63c91f95173f9c2a67c7c526b2cea976828a0e7fced9cdcead2802dc10f8a4", size = 2270718, upload-time = "2026-03-09T13:13:19.421Z" }, + { url = "https://files.pythonhosted.org/packages/be/6c/28f17390b62b8f2f520e2915095b3c94d88681ecf0041e75389d9667f202/kiwisolver-1.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:beb7f344487cdcb9e1efe4b7a29681b74d34c08f0043a327a74da852a6749e7b", size = 73480, upload-time = "2026-03-09T13:13:20.818Z" }, + { url = "https://files.pythonhosted.org/packages/d8/0e/2ee5debc4f77a625778fec5501ff3e8036fe361b7ee28ae402a485bb9694/kiwisolver-1.5.0-cp311-cp311-win_arm64.whl", hash = "sha256:ad4ae4ffd1ee9cd11357b4c66b612da9888f4f4daf2f36995eda64bd45370cac", size = 64930, upload-time = "2026-03-09T13:13:21.997Z" }, + { url = "https://files.pythonhosted.org/packages/4d/b2/818b74ebea34dabe6d0c51cb1c572e046730e64844da6ed646d5298c40ce/kiwisolver-1.5.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:4e9750bc21b886308024f8a54ccb9a2cc38ac9fa813bf4348434e3d54f337ff9", size = 123158, upload-time = "2026-03-09T13:13:23.127Z" }, + { url = "https://files.pythonhosted.org/packages/bf/d9/405320f8077e8e1c5c4bd6adc45e1e6edf6d727b6da7f2e2533cf58bff71/kiwisolver-1.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:72ec46b7eba5b395e0a7b63025490d3214c11013f4aacb4f5e8d6c3041829588", size = 66388, upload-time = "2026-03-09T13:13:24.765Z" }, + { url = "https://files.pythonhosted.org/packages/99/9f/795fedf35634f746151ca8839d05681ceb6287fbed6cc1c9bf235f7887c2/kiwisolver-1.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ed3a984b31da7481b103f68776f7128a89ef26ed40f4dc41a2223cda7fb24819", size = 64068, upload-time = "2026-03-09T13:13:25.878Z" }, + { url = "https://files.pythonhosted.org/packages/c4/13/680c54afe3e65767bed7ec1a15571e1a2f1257128733851ade24abcefbcc/kiwisolver-1.5.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb5136fb5352d3f422df33f0c879a1b0c204004324150cc3b5e3c4f310c9049f", size = 1477934, upload-time = "2026-03-09T13:13:27.166Z" }, + { url = "https://files.pythonhosted.org/packages/c8/2f/cebfcdb60fd6a9b0f6b47a9337198bcbad6fbe15e68189b7011fd914911f/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b2af221f268f5af85e776a73d62b0845fc8baf8ef0abfae79d29c77d0e776aaf", size = 1278537, upload-time = "2026-03-09T13:13:28.707Z" }, + { url = "https://files.pythonhosted.org/packages/f2/0d/9b782923aada3fafb1d6b84e13121954515c669b18af0c26e7d21f579855/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b0f172dc8ffaccb8522d7c5d899de00133f2f1ca7b0a49b7da98e901de87bf2d", size = 1296685, upload-time = "2026-03-09T13:13:30.528Z" }, + { url = "https://files.pythonhosted.org/packages/27/70/83241b6634b04fe44e892688d5208332bde130f38e610c0418f9ede47ded/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6ab8ba9152203feec73758dad83af9a0bbe05001eb4639e547207c40cfb52083", size = 1346024, upload-time = "2026-03-09T13:13:32.818Z" }, + { url = "https://files.pythonhosted.org/packages/e4/db/30ed226fb271ae1a6431fc0fe0edffb2efe23cadb01e798caeb9f2ceae8f/kiwisolver-1.5.0-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:cdee07c4d7f6d72008d3f73b9bf027f4e11550224c7c50d8df1ae4a37c1402a6", size = 987241, upload-time = "2026-03-09T13:13:34.435Z" }, + { url = "https://files.pythonhosted.org/packages/ec/bd/c314595208e4c9587652d50959ead9e461995389664e490f4dce7ff0f782/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7c60d3c9b06fb23bd9c6139281ccbdc384297579ae037f08ae90c69f6845c0b1", size = 2227742, upload-time = "2026-03-09T13:13:36.4Z" }, + { url = "https://files.pythonhosted.org/packages/c1/43/0499cec932d935229b5543d073c2b87c9c22846aab48881e9d8d6e742a2d/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e315e5ec90d88e140f57696ff85b484ff68bb311e36f2c414aa4286293e6dee0", size = 2323966, upload-time = "2026-03-09T13:13:38.204Z" }, + { url = "https://files.pythonhosted.org/packages/3d/6f/79b0d760907965acfd9d61826a3d41f8f093c538f55cd2633d3f0db269f6/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:1465387ac63576c3e125e5337a6892b9e99e0627d52317f3ca79e6930d889d15", size = 1977417, upload-time = "2026-03-09T13:13:39.966Z" }, + { url = "https://files.pythonhosted.org/packages/ab/31/01d0537c41cb75a551a438c3c7a80d0c60d60b81f694dac83dd436aec0d0/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:530a3fd64c87cffa844d4b6b9768774763d9caa299e9b75d8eca6a4423b31314", size = 2491238, upload-time = "2026-03-09T13:13:41.698Z" }, + { url = "https://files.pythonhosted.org/packages/e4/34/8aefdd0be9cfd00a44509251ba864f5caf2991e36772e61c408007e7f417/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1d9daea4ea6b9be74fe2f01f7fbade8d6ffab263e781274cffca0dba9be9eec9", size = 2294947, upload-time = "2026-03-09T13:13:43.343Z" }, + { url = "https://files.pythonhosted.org/packages/ad/cf/0348374369ca588f8fe9c338fae49fa4e16eeb10ffb3d012f23a54578a9e/kiwisolver-1.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:f18c2d9782259a6dc132fdc7a63c168cbc74b35284b6d75c673958982a378384", size = 73569, upload-time = "2026-03-09T13:13:45.792Z" }, + { url = "https://files.pythonhosted.org/packages/28/26/192b26196e2316e2bd29deef67e37cdf9870d9af8e085e521afff0fed526/kiwisolver-1.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:f7c7553b13f69c1b29a5bde08ddc6d9d0c8bfb84f9ed01c30db25944aeb852a7", size = 64997, upload-time = "2026-03-09T13:13:46.878Z" }, + { url = "https://files.pythonhosted.org/packages/1c/fa/2910df836372d8761bb6eff7d8bdcb1613b5c2e03f260efe7abe34d388a7/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-macosx_10_13_x86_64.whl", hash = "sha256:5ae8e62c147495b01a0f4765c878e9bfdf843412446a247e28df59936e99e797", size = 130262, upload-time = "2026-03-09T13:15:35.629Z" }, + { url = "https://files.pythonhosted.org/packages/0f/41/c5f71f9f00aabcc71fee8b7475e3f64747282580c2fe748961ba29b18385/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:f6764a4ccab3078db14a632420930f6186058750df066b8ea2a7106df91d3203", size = 138036, upload-time = "2026-03-09T13:15:36.894Z" }, + { url = "https://files.pythonhosted.org/packages/fa/06/7399a607f434119c6e1fdc8ec89a8d51ccccadf3341dee4ead6bd14caaf5/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c31c13da98624f957b0fb1b5bae5383b2333c2c3f6793d9825dd5ce79b525cb7", size = 194295, upload-time = "2026-03-09T13:15:38.22Z" }, + { url = "https://files.pythonhosted.org/packages/b5/91/53255615acd2a1eaca307ede3c90eb550bae9c94581f8c00081b6b1c8f44/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-win_amd64.whl", hash = "sha256:1f1489f769582498610e015a8ef2d36f28f505ab3096d0e16b4858a9ec214f57", size = 75987, upload-time = "2026-03-09T13:15:39.65Z" }, + { url = "https://files.pythonhosted.org/packages/17/6f/6fd4f690a40c2582fa34b97d2678f718acf3706b91d270c65ecb455d0a06/kiwisolver-1.5.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:295d9ffe712caa9f8a3081de8d32fc60191b4b51c76f02f951fd8407253528f4", size = 59606, upload-time = "2026-03-09T13:15:40.81Z" }, + { url = "https://files.pythonhosted.org/packages/82/a0/2355d5e3b338f13ce63f361abb181e3b6ea5fffdb73f739b3e80efa76159/kiwisolver-1.5.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:51e8c4084897de9f05898c2c2a39af6318044ae969d46ff7a34ed3f96274adca", size = 57537, upload-time = "2026-03-09T13:15:42.071Z" }, + { url = "https://files.pythonhosted.org/packages/c8/b9/1d50e610ecadebe205b71d6728fd224ce0e0ca6aba7b9cbe1da049203ac5/kiwisolver-1.5.0-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b83af57bdddef03c01a9138034c6ff03181a3028d9a1003b301eb1a55e161a3f", size = 79888, upload-time = "2026-03-09T13:15:43.317Z" }, + { url = "https://files.pythonhosted.org/packages/cd/ee/b85ffcd75afed0357d74f0e6fc02a4507da441165de1ca4760b9f496390d/kiwisolver-1.5.0-pp310-pypy310_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf4679a3d71012a7c2bf360e5cd878fbd5e4fcac0896b56393dec239d81529ed", size = 77584, upload-time = "2026-03-09T13:15:44.605Z" }, + { url = "https://files.pythonhosted.org/packages/6b/dd/644d0dde6010a8583b4cd66dd41c5f83f5325464d15c4f490b3340ab73b4/kiwisolver-1.5.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:41024ed50e44ab1a60d3fe0a9d15a4ccc9f5f2b1d814ff283c8d01134d5b81bc", size = 73390, upload-time = "2026-03-09T13:15:45.832Z" }, + { url = "https://files.pythonhosted.org/packages/e9/eb/5fcbbbf9a0e2c3a35effb88831a483345326bbc3a030a3b5b69aee647f84/kiwisolver-1.5.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ec4c85dc4b687c7f7f15f553ff26a98bfe8c58f5f7f0ac8905f0ba4c7be60232", size = 59532, upload-time = "2026-03-09T13:15:47.047Z" }, + { url = "https://files.pythonhosted.org/packages/c3/9b/e17104555bb4db148fd52327feea1e96be4b88e8e008b029002c281a21ab/kiwisolver-1.5.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:12e91c215a96e39f57989c8912ae761286ac5a9584d04030ceb3368a357f017a", size = 57420, upload-time = "2026-03-09T13:15:48.199Z" }, + { url = "https://files.pythonhosted.org/packages/48/44/2b5b95b7aa39fb2d8d9d956e0f3d5d45aef2ae1d942d4c3ffac2f9cfed1a/kiwisolver-1.5.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:be4a51a55833dc29ab5d7503e7bcb3b3af3402d266018137127450005cdfe737", size = 79892, upload-time = "2026-03-09T13:15:49.694Z" }, + { url = "https://files.pythonhosted.org/packages/52/7d/7157f9bba6b455cfb4632ed411e199fc8b8977642c2b12082e1bd9e6d173/kiwisolver-1.5.0-pp311-pypy311_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:daae526907e262de627d8f70058a0f64acc9e2641c164c99c8f594b34a799a16", size = 77603, upload-time = "2026-03-09T13:15:50.945Z" }, + { url = "https://files.pythonhosted.org/packages/0a/dd/8050c947d435c8d4bc94e3252f4d8bb8a76cfb424f043a8680be637a57f1/kiwisolver-1.5.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:59cd8683f575d96df5bb48f6add94afc055012c29e28124fcae2b63661b9efb1", size = 73558, upload-time = "2026-03-09T13:15:52.112Z" }, +] + [[package]] name = "mako" version = "1.3.10" @@ -913,6 +1119,53 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, ] +[[package]] +name = "matplotlib" +version = "3.10.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "contourpy", version = "1.3.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "contourpy", version = "1.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "cycler" }, + { name = "fonttools" }, + { name = "kiwisolver" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "packaging" }, + { name = "pillow" }, + { name = "pyparsing" }, + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8a/76/d3c6e3a13fe484ebe7718d14e269c9569c4eb0020a968a327acb3b9a8fe6/matplotlib-3.10.8.tar.gz", hash = "sha256:2299372c19d56bcd35cf05a2738308758d32b9eaed2371898d8f5bd33f084aa3", size = 34806269, upload-time = "2025-12-10T22:56:51.155Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/be/a30bd917018ad220c400169fba298f2bb7003c8ccbc0c3e24ae2aacad1e8/matplotlib-3.10.8-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:00270d217d6b20d14b584c521f810d60c5c78406dc289859776550df837dcda7", size = 8239828, upload-time = "2025-12-10T22:55:02.313Z" }, + { url = "https://files.pythonhosted.org/packages/58/27/ca01e043c4841078e82cf6e80a6993dfecd315c3d79f5f3153afbb8e1ec6/matplotlib-3.10.8-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:37b3c1cc42aa184b3f738cfa18c1c1d72fd496d85467a6cf7b807936d39aa656", size = 8128050, upload-time = "2025-12-10T22:55:04.997Z" }, + { url = "https://files.pythonhosted.org/packages/cb/aa/7ab67f2b729ae6a91bcf9dcac0affb95fb8c56f7fd2b2af894ae0b0cf6fa/matplotlib-3.10.8-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ee40c27c795bda6a5292e9cff9890189d32f7e3a0bf04e0e3c9430c4a00c37df", size = 8700452, upload-time = "2025-12-10T22:55:07.47Z" }, + { url = "https://files.pythonhosted.org/packages/73/ae/2d5817b0acee3c49b7e7ccfbf5b273f284957cc8e270adf36375db353190/matplotlib-3.10.8-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a48f2b74020919552ea25d222d5cc6af9ca3f4eb43a93e14d068457f545c2a17", size = 9534928, upload-time = "2025-12-10T22:55:10.566Z" }, + { url = "https://files.pythonhosted.org/packages/c9/5b/8e66653e9f7c39cb2e5cab25fce4810daffa2bff02cbf5f3077cea9e942c/matplotlib-3.10.8-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f254d118d14a7f99d616271d6c3c27922c092dac11112670b157798b89bf4933", size = 9586377, upload-time = "2025-12-10T22:55:12.362Z" }, + { url = "https://files.pythonhosted.org/packages/e2/e2/fd0bbadf837f81edb0d208ba8f8cb552874c3b16e27cb91a31977d90875d/matplotlib-3.10.8-cp310-cp310-win_amd64.whl", hash = "sha256:f9b587c9c7274c1613a30afabf65a272114cd6cdbe67b3406f818c79d7ab2e2a", size = 8128127, upload-time = "2025-12-10T22:55:14.436Z" }, + { url = "https://files.pythonhosted.org/packages/f8/86/de7e3a1cdcfc941483af70609edc06b83e7c8a0e0dc9ac325200a3f4d220/matplotlib-3.10.8-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:6be43b667360fef5c754dda5d25a32e6307a03c204f3c0fc5468b78fa87b4160", size = 8251215, upload-time = "2025-12-10T22:55:16.175Z" }, + { url = "https://files.pythonhosted.org/packages/fd/14/baad3222f424b19ce6ad243c71de1ad9ec6b2e4eb1e458a48fdc6d120401/matplotlib-3.10.8-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a2b336e2d91a3d7006864e0990c83b216fcdca64b5a6484912902cef87313d78", size = 8139625, upload-time = "2025-12-10T22:55:17.712Z" }, + { url = "https://files.pythonhosted.org/packages/8f/a0/7024215e95d456de5883e6732e708d8187d9753a21d32f8ddb3befc0c445/matplotlib-3.10.8-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:efb30e3baaea72ce5928e32bab719ab4770099079d66726a62b11b1ef7273be4", size = 8712614, upload-time = "2025-12-10T22:55:20.8Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f4/b8347351da9a5b3f41e26cf547252d861f685c6867d179a7c9d60ad50189/matplotlib-3.10.8-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d56a1efd5bfd61486c8bc968fa18734464556f0fb8e51690f4ac25d85cbbbbc2", size = 9540997, upload-time = "2025-12-10T22:55:23.258Z" }, + { url = "https://files.pythonhosted.org/packages/9e/c0/c7b914e297efe0bc36917bf216b2acb91044b91e930e878ae12981e461e5/matplotlib-3.10.8-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:238b7ce5717600615c895050239ec955d91f321c209dd110db988500558e70d6", size = 9596825, upload-time = "2025-12-10T22:55:25.217Z" }, + { url = "https://files.pythonhosted.org/packages/6f/d3/a4bbc01c237ab710a1f22b4da72f4ff6d77eb4c7735ea9811a94ae239067/matplotlib-3.10.8-cp311-cp311-win_amd64.whl", hash = "sha256:18821ace09c763ec93aef5eeff087ee493a24051936d7b9ebcad9662f66501f9", size = 8135090, upload-time = "2025-12-10T22:55:27.162Z" }, + { url = "https://files.pythonhosted.org/packages/89/dd/a0b6588f102beab33ca6f5218b31725216577b2a24172f327eaf6417d5c9/matplotlib-3.10.8-cp311-cp311-win_arm64.whl", hash = "sha256:bab485bcf8b1c7d2060b4fcb6fc368a9e6f4cd754c9c2fea281f4be21df394a2", size = 8012377, upload-time = "2025-12-10T22:55:29.185Z" }, + { url = "https://files.pythonhosted.org/packages/9e/67/f997cdcbb514012eb0d10cd2b4b332667997fb5ebe26b8d41d04962fa0e6/matplotlib-3.10.8-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:64fcc24778ca0404ce0cb7b6b77ae1f4c7231cdd60e6778f999ee05cbd581b9a", size = 8260453, upload-time = "2025-12-10T22:55:30.709Z" }, + { url = "https://files.pythonhosted.org/packages/7e/65/07d5f5c7f7c994f12c768708bd2e17a4f01a2b0f44a1c9eccad872433e2e/matplotlib-3.10.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b9a5ca4ac220a0cdd1ba6bcba3608547117d30468fefce49bb26f55c1a3d5c58", size = 8148321, upload-time = "2025-12-10T22:55:33.265Z" }, + { url = "https://files.pythonhosted.org/packages/3e/f3/c5195b1ae57ef85339fd7285dfb603b22c8b4e79114bae5f4f0fcf688677/matplotlib-3.10.8-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3ab4aabc72de4ff77b3ec33a6d78a68227bf1123465887f9905ba79184a1cc04", size = 8716944, upload-time = "2025-12-10T22:55:34.922Z" }, + { url = "https://files.pythonhosted.org/packages/00/f9/7638f5cc82ec8a7aa005de48622eecc3ed7c9854b96ba15bd76b7fd27574/matplotlib-3.10.8-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:24d50994d8c5816ddc35411e50a86ab05f575e2530c02752e02538122613371f", size = 9550099, upload-time = "2025-12-10T22:55:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/57/61/78cd5920d35b29fd2a0fe894de8adf672ff52939d2e9b43cb83cd5ce1bc7/matplotlib-3.10.8-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:99eefd13c0dc3b3c1b4d561c1169e65fe47aab7b8158754d7c084088e2329466", size = 9613040, upload-time = "2025-12-10T22:55:38.715Z" }, + { url = "https://files.pythonhosted.org/packages/30/4e/c10f171b6e2f44d9e3a2b96efa38b1677439d79c99357600a62cc1e9594e/matplotlib-3.10.8-cp312-cp312-win_amd64.whl", hash = "sha256:dd80ecb295460a5d9d260df63c43f4afbdd832d725a531f008dad1664f458adf", size = 8142717, upload-time = "2025-12-10T22:55:41.103Z" }, + { url = "https://files.pythonhosted.org/packages/f1/76/934db220026b5fef85f45d51a738b91dea7d70207581063cd9bd8fafcf74/matplotlib-3.10.8-cp312-cp312-win_arm64.whl", hash = "sha256:3c624e43ed56313651bc18a47f838b60d7b8032ed348911c54906b130b20071b", size = 8012751, upload-time = "2025-12-10T22:55:42.684Z" }, + { url = "https://files.pythonhosted.org/packages/f5/43/31d59500bb950b0d188e149a2e552040528c13d6e3d6e84d0cccac593dcd/matplotlib-3.10.8-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:f97aeb209c3d2511443f8797e3e5a569aebb040d4f8bc79aa3ee78a8fb9e3dd8", size = 8237252, upload-time = "2025-12-10T22:56:39.529Z" }, + { url = "https://files.pythonhosted.org/packages/0c/2c/615c09984f3c5f907f51c886538ad785cf72e0e11a3225de2c0f9442aecc/matplotlib-3.10.8-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:fb061f596dad3a0f52b60dc6a5dec4a0c300dec41e058a7efe09256188d170b7", size = 8124693, upload-time = "2025-12-10T22:56:41.758Z" }, + { url = "https://files.pythonhosted.org/packages/91/e1/2757277a1c56041e1fc104b51a0f7b9a4afc8eb737865d63cababe30bc61/matplotlib-3.10.8-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:12d90df9183093fcd479f4172ac26b322b1248b15729cb57f42f71f24c7e37a3", size = 8702205, upload-time = "2025-12-10T22:56:43.415Z" }, + { url = "https://files.pythonhosted.org/packages/04/30/3afaa31c757f34b7725ab9d2ba8b48b5e89c2019c003e7d0ead143aabc5a/matplotlib-3.10.8-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:6da7c2ce169267d0d066adcf63758f0604aa6c3eebf67458930f9d9b79ad1db1", size = 8249198, upload-time = "2025-12-10T22:56:45.584Z" }, + { url = "https://files.pythonhosted.org/packages/48/2f/6334aec331f57485a642a7c8be03cb286f29111ae71c46c38b363230063c/matplotlib-3.10.8-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:9153c3292705be9f9c64498a8872118540c3f4123d1a1c840172edf262c8be4a", size = 8136817, upload-time = "2025-12-10T22:56:47.339Z" }, + { url = "https://files.pythonhosted.org/packages/73/e4/6d6f14b2a759c622f191b2d67e9075a3f56aaccb3be4bb9bb6890030d0a0/matplotlib-3.10.8-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1ae029229a57cd1e8fe542485f27e7ca7b23aa9e8944ddb4985d0bc444f1eca2", size = 8713867, upload-time = "2025-12-10T22:56:48.954Z" }, +] + [[package]] name = "matplotlib-inline" version = "0.2.1" @@ -978,7 +1231,8 @@ name = "networkx" version = "3.4.2" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version < '3.11'", + "python_full_version < '3.11' and sys_platform == 'win32'", + "python_full_version < '3.11' and sys_platform != 'win32'", ] 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 = [ @@ -1007,7 +1261,8 @@ name = "numpy" version = "2.2.6" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version < '3.11'", + "python_full_version < '3.11' and sys_platform == 'win32'", + "python_full_version < '3.11' and sys_platform != 'win32'", ] 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 = [ @@ -1226,6 +1481,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a2/eb/86626c1bbc2edb86323022371c39aa48df6fd8b0a1647bc274577f72e90b/nvidia_nvtx_cu12-12.8.90-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5b17e2001cc0d751a5bc2c6ec6d26ad95913324a4adb86788c944f8ce9ba441f", size = 89954, upload-time = "2025-03-07T01:42:44.131Z" }, ] +[[package]] +name = "opencv-python" +version = "4.13.0.92" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/6f/5a28fef4c4a382be06afe3938c64cc168223016fa520c5abaf37e8862aa5/opencv_python-4.13.0.92-cp37-abi3-macosx_13_0_arm64.whl", hash = "sha256:caf60c071ec391ba51ed00a4a920f996d0b64e3e46068aac1f646b5de0326a19", size = 46247052, upload-time = "2026-02-05T07:01:25.046Z" }, + { url = "https://files.pythonhosted.org/packages/08/ac/6c98c44c650b8114a0fb901691351cfb3956d502e8e9b5cd27f4ee7fbf2f/opencv_python-4.13.0.92-cp37-abi3-macosx_14_0_x86_64.whl", hash = "sha256:5868a8c028a0b37561579bfb8ac1875babdc69546d236249fff296a8c010ccf9", size = 32568781, upload-time = "2026-02-05T07:01:41.379Z" }, + { url = "https://files.pythonhosted.org/packages/3e/51/82fed528b45173bf629fa44effb76dff8bc9f4eeaee759038362dfa60237/opencv_python-4.13.0.92-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0bc2596e68f972ca452d80f444bc404e08807d021fbba40df26b61b18e01838a", size = 47685527, upload-time = "2026-02-05T06:59:11.24Z" }, + { url = "https://files.pythonhosted.org/packages/db/07/90b34a8e2cf9c50fe8ed25cac9011cde0676b4d9d9c973751ac7616223a2/opencv_python-4.13.0.92-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:402033cddf9d294693094de5ef532339f14ce821da3ad7df7c9f6e8316da32cf", size = 70460872, upload-time = "2026-02-05T06:59:19.162Z" }, + { url = "https://files.pythonhosted.org/packages/02/6d/7a9cc719b3eaf4377b9c2e3edeb7ed3a81de41f96421510c0a169ca3cfd4/opencv_python-4.13.0.92-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:bccaabf9eb7f897ca61880ce2869dcd9b25b72129c28478e7f2a5e8dee945616", size = 46708208, upload-time = "2026-02-05T06:59:15.419Z" }, + { url = "https://files.pythonhosted.org/packages/fd/55/b3b49a1b97aabcfbbd6c7326df9cb0b6fa0c0aefa8e89d500939e04aa229/opencv_python-4.13.0.92-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:620d602b8f7d8b8dab5f4b99c6eb353e78d3fb8b0f53db1bd258bb1aa001c1d5", size = 72927042, upload-time = "2026-02-05T06:59:23.389Z" }, + { url = "https://files.pythonhosted.org/packages/fb/17/de5458312bcb07ddf434d7bfcb24bb52c59635ad58c6e7c751b48949b009/opencv_python-4.13.0.92-cp37-abi3-win32.whl", hash = "sha256:372fe164a3148ac1ca51e5f3ad0541a4a276452273f503441d718fab9c5e5f59", size = 30932638, upload-time = "2026-02-05T07:02:14.98Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a5/1be1516390333ff9be3a9cb648c9f33df79d5096e5884b5df71a588af463/opencv_python-4.13.0.92-cp37-abi3-win_amd64.whl", hash = "sha256:423d934c9fafb91aad38edf26efb46da91ffbc05f3f59c4b0c72e699720706f5", size = 40212062, upload-time = "2026-02-05T07:02:12.724Z" }, +] + [[package]] name = "orjson" version = "3.11.7" @@ -1291,7 +1565,8 @@ name = "pandas" version = "2.3.3" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version < '3.11'", + "python_full_version < '3.11' and sys_platform == 'win32'", + "python_full_version < '3.11' and sys_platform != 'win32'", ] dependencies = [ { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, @@ -1384,7 +1659,7 @@ name = "pexpect" version = "4.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "ptyprocess", marker = "(python_full_version < '3.11' and sys_platform == 'emscripten') or (python_full_version < '3.11' and sys_platform == 'win32') or (sys_platform != 'emscripten' and sys_platform != 'win32')" }, + { name = "ptyprocess", marker = "(python_full_version < '3.11' and sys_platform == 'emscripten') or (sys_platform != 'emscripten' and sys_platform != 'win32')" }, ] 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 = [ @@ -1466,6 +1741,34 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d3/89/a41c2643fc8eabeb84791acb9d0e4d139b1e4b53473cc4dae947b5fa33ed/plyer-2.1.0-py2.py3-none-any.whl", hash = "sha256:1b1772060df8b3045ed4f08231690ec8f7de30f5a004aa1724665a9074eed113", size = 142266, upload-time = "2022-11-12T13:36:47.181Z" }, ] +[[package]] +name = "polars" +version = "1.39.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "polars-runtime-32" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/93/ab/f19e592fce9e000da49c96bf35e77cef67f9cb4b040bfa538a2764c0263e/polars-1.39.3.tar.gz", hash = "sha256:2e016c7f3e8d14fa777ef86fe0477cec6c67023a20ba4c94d6e8431eefe4a63c", size = 728987, upload-time = "2026-03-20T11:16:24.836Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b4/db/08f4ca10c5018813e7e0b59e4472302328b3d2ab1512f5a2157a814540e0/polars-1.39.3-py3-none-any.whl", hash = "sha256:c2b955ccc0a08a2bc9259785decf3d5c007b489b523bf2390cf21cec2bb82a56", size = 823985, upload-time = "2026-03-20T11:14:23.619Z" }, +] + +[[package]] +name = "polars-runtime-32" +version = "1.39.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/17/39/c8688696bc22b6c501e3b82ef3be10e543c07a785af5660f30997cd22dd2/polars_runtime_32-1.39.3.tar.gz", hash = "sha256:c728e4f469cafab501947585f36311b8fb222d3e934c6209e83791e0df20b29d", size = 2872335, upload-time = "2026-03-20T11:16:26.581Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/74/1b41205f7368c9375ab1dea91178eaa20435fe3eff036390a53a7660b416/polars_runtime_32-1.39.3-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:425c0b220b573fa097b4042edff73114cc6d23432a21dfd2dc41adf329d7d2e9", size = 45273243, upload-time = "2026-03-20T11:14:26.691Z" }, + { url = "https://files.pythonhosted.org/packages/90/bf/297716b3095fe719be20fcf7af1d2b6ab069c38199bbace2469608a69b3a/polars_runtime_32-1.39.3-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:ef5884711e3c617d7dc93519a7d038e242f5741cfe5fe9afd32d58845d86c562", size = 40842924, upload-time = "2026-03-20T11:14:31.154Z" }, + { url = "https://files.pythonhosted.org/packages/3d/3e/e65236d9d0d9babfa0ecba593413c06530fca60a8feb8f66243aa5dba92e/polars_runtime_32-1.39.3-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:06b47f535eb1f97a9a1e5b0053ef50db3a4276e241178e37bbb1a38b1fa53b14", size = 43220650, upload-time = "2026-03-20T11:14:35.458Z" }, + { url = "https://files.pythonhosted.org/packages/b0/15/fc3e43f3fdf3f20b7dfb5abe871ab6162cf8fb4aeabf4cfad822d5dc4c79/polars_runtime_32-1.39.3-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8bc9e13dc1d2e828331f2fe8ccbc9757554dc4933a8d3e85e906b988178f95ed", size = 46877498, upload-time = "2026-03-20T11:14:40.14Z" }, + { url = "https://files.pythonhosted.org/packages/3c/81/bd5f895919e32c6ab0a7786cd0c0ca961cb03152c47c3645808b54383f31/polars_runtime_32-1.39.3-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:363d49e3a3e638fc943e2b9887940300a7d06789930855a178a4727949259dc2", size = 43380176, upload-time = "2026-03-20T11:14:45.566Z" }, + { url = "https://files.pythonhosted.org/packages/7a/3e/c86433c3b5ec0315bdfc7640d0c15d41f1216c0103a0eab9a9b5147d6c4c/polars_runtime_32-1.39.3-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7c206bdcc7bc62ea038d6adea8e44b02f0e675e0191a54c810703b4895208ea4", size = 46485933, upload-time = "2026-03-20T11:14:51.155Z" }, + { url = "https://files.pythonhosted.org/packages/54/ce/200b310cf91f98e652eb6ea09fdb3a9718aa0293ebf113dce325797c8572/polars_runtime_32-1.39.3-cp310-abi3-win_amd64.whl", hash = "sha256:d66ca522517554a883446957539c40dc7b75eb0c2220357fb28bc8940d305339", size = 46995458, upload-time = "2026-03-20T11:14:56.074Z" }, + { url = "https://files.pythonhosted.org/packages/da/76/2d48927e0aa2abbdde08cbf4a2536883b73277d47fbeca95e952de86df34/polars_runtime_32-1.39.3-cp310-abi3-win_arm64.whl", hash = "sha256:f49f51461de63f13e5dd4eb080421c8f23f856945f3f8bd5b2b1f59da52c2860", size = 41857648, upload-time = "2026-03-20T11:15:01.142Z" }, +] + [[package]] name = "prompt-toolkit" version = "3.0.52" @@ -1478,6 +1781,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" }, ] +[[package]] +name = "psutil" +version = "7.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" }, + { url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" }, + { url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" }, + { url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" }, + { url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" }, + { url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" }, + { url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, +] + [[package]] name = "psycopg2-binary" version = "2.9.11" @@ -1689,12 +2008,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/94/24/40bbd10854f110ba5812881553d017c276e52c477dfddf151cdac3667f81/pyobjus-1.2.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:f4da2314b85a57b67e1101493b94da245f03cef8465da698ff5949bec13e8d37", size = 529439, upload-time = "2025-12-29T14:48:08.741Z" }, ] +[[package]] +name = "pyparsing" +version = "3.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574, upload-time = "2026-01-21T03:57:59.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" }, +] + [[package]] name = "pypiwin32" version = "223" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pywin32", marker = "python_full_version < '3.11' or sys_platform == 'win32'" }, + { name = "pywin32", marker = "sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/13/e8/4f38eb30c4dae36634a53c5b2cd73b517ea3607e10d00f61f2494449cec0/pypiwin32-223.tar.gz", hash = "sha256:71be40c1fbd28594214ecaecb58e7aa8b708eabfa0125c8a109ebd51edbd776a", size = 622, upload-time = "2018-02-26T00:43:23.994Z" } wheels = [ @@ -1940,6 +2268,87 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/58/5b/632a58724221ef03d78ab65062e82a1010e1bef8e8e0b9d7c6d7b8044841/safetensors-0.7.0-pp310-pypy310_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:473b32699f4200e69801bf5abf93f1a4ecd432a70984df164fc22ccf39c4a6f3", size = 531885, upload-time = "2025-11-19T15:18:27.146Z" }, ] +[[package]] +name = "scipy" +version = "1.15.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11' and sys_platform == 'win32'", + "python_full_version < '3.11' and sys_platform != 'win32'", +] +dependencies = [ + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0f/37/6964b830433e654ec7485e45a00fc9a27cf868d622838f6b6d9c5ec0d532/scipy-1.15.3.tar.gz", hash = "sha256:eae3cf522bc7df64b42cad3925c876e1b0b6c35c1337c93e12c0f366f55b0eaf", size = 59419214, upload-time = "2025-05-08T16:13:05.955Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/2f/4966032c5f8cc7e6a60f1b2e0ad686293b9474b65246b0c642e3ef3badd0/scipy-1.15.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:a345928c86d535060c9c2b25e71e87c39ab2f22fc96e9636bd74d1dbf9de448c", size = 38702770, upload-time = "2025-05-08T16:04:20.849Z" }, + { url = "https://files.pythonhosted.org/packages/a0/6e/0c3bf90fae0e910c274db43304ebe25a6b391327f3f10b5dcc638c090795/scipy-1.15.3-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:ad3432cb0f9ed87477a8d97f03b763fd1d57709f1bbde3c9369b1dff5503b253", size = 30094511, upload-time = "2025-05-08T16:04:27.103Z" }, + { url = "https://files.pythonhosted.org/packages/ea/b1/4deb37252311c1acff7f101f6453f0440794f51b6eacb1aad4459a134081/scipy-1.15.3-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:aef683a9ae6eb00728a542b796f52a5477b78252edede72b8327a886ab63293f", size = 22368151, upload-time = "2025-05-08T16:04:31.731Z" }, + { url = "https://files.pythonhosted.org/packages/38/7d/f457626e3cd3c29b3a49ca115a304cebb8cc6f31b04678f03b216899d3c6/scipy-1.15.3-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:1c832e1bd78dea67d5c16f786681b28dd695a8cb1fb90af2e27580d3d0967e92", size = 25121732, upload-time = "2025-05-08T16:04:36.596Z" }, + { url = "https://files.pythonhosted.org/packages/db/0a/92b1de4a7adc7a15dcf5bddc6e191f6f29ee663b30511ce20467ef9b82e4/scipy-1.15.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:263961f658ce2165bbd7b99fa5135195c3a12d9bef045345016b8b50c315cb82", size = 35547617, upload-time = "2025-05-08T16:04:43.546Z" }, + { url = "https://files.pythonhosted.org/packages/8e/6d/41991e503e51fc1134502694c5fa7a1671501a17ffa12716a4a9151af3df/scipy-1.15.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e2abc762b0811e09a0d3258abee2d98e0c703eee49464ce0069590846f31d40", size = 37662964, upload-time = "2025-05-08T16:04:49.431Z" }, + { url = "https://files.pythonhosted.org/packages/25/e1/3df8f83cb15f3500478c889be8fb18700813b95e9e087328230b98d547ff/scipy-1.15.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ed7284b21a7a0c8f1b6e5977ac05396c0d008b89e05498c8b7e8f4a1423bba0e", size = 37238749, upload-time = "2025-05-08T16:04:55.215Z" }, + { url = "https://files.pythonhosted.org/packages/93/3e/b3257cf446f2a3533ed7809757039016b74cd6f38271de91682aa844cfc5/scipy-1.15.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5380741e53df2c566f4d234b100a484b420af85deb39ea35a1cc1be84ff53a5c", size = 40022383, upload-time = "2025-05-08T16:05:01.914Z" }, + { url = "https://files.pythonhosted.org/packages/d1/84/55bc4881973d3f79b479a5a2e2df61c8c9a04fcb986a213ac9c02cfb659b/scipy-1.15.3-cp310-cp310-win_amd64.whl", hash = "sha256:9d61e97b186a57350f6d6fd72640f9e99d5a4a2b8fbf4b9ee9a841eab327dc13", size = 41259201, upload-time = "2025-05-08T16:05:08.166Z" }, + { url = "https://files.pythonhosted.org/packages/96/ab/5cc9f80f28f6a7dff646c5756e559823614a42b1939d86dd0ed550470210/scipy-1.15.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:993439ce220d25e3696d1b23b233dd010169b62f6456488567e830654ee37a6b", size = 38714255, upload-time = "2025-05-08T16:05:14.596Z" }, + { url = "https://files.pythonhosted.org/packages/4a/4a/66ba30abe5ad1a3ad15bfb0b59d22174012e8056ff448cb1644deccbfed2/scipy-1.15.3-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:34716e281f181a02341ddeaad584205bd2fd3c242063bd3423d61ac259ca7eba", size = 30111035, upload-time = "2025-05-08T16:05:20.152Z" }, + { url = "https://files.pythonhosted.org/packages/4b/fa/a7e5b95afd80d24313307f03624acc65801846fa75599034f8ceb9e2cbf6/scipy-1.15.3-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:3b0334816afb8b91dab859281b1b9786934392aa3d527cd847e41bb6f45bee65", size = 22384499, upload-time = "2025-05-08T16:05:24.494Z" }, + { url = "https://files.pythonhosted.org/packages/17/99/f3aaddccf3588bb4aea70ba35328c204cadd89517a1612ecfda5b2dd9d7a/scipy-1.15.3-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:6db907c7368e3092e24919b5e31c76998b0ce1684d51a90943cb0ed1b4ffd6c1", size = 25152602, upload-time = "2025-05-08T16:05:29.313Z" }, + { url = "https://files.pythonhosted.org/packages/56/c5/1032cdb565f146109212153339f9cb8b993701e9fe56b1c97699eee12586/scipy-1.15.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:721d6b4ef5dc82ca8968c25b111e307083d7ca9091bc38163fb89243e85e3889", size = 35503415, upload-time = "2025-05-08T16:05:34.699Z" }, + { url = "https://files.pythonhosted.org/packages/bd/37/89f19c8c05505d0601ed5650156e50eb881ae3918786c8fd7262b4ee66d3/scipy-1.15.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39cb9c62e471b1bb3750066ecc3a3f3052b37751c7c3dfd0fd7e48900ed52982", size = 37652622, upload-time = "2025-05-08T16:05:40.762Z" }, + { url = "https://files.pythonhosted.org/packages/7e/31/be59513aa9695519b18e1851bb9e487de66f2d31f835201f1b42f5d4d475/scipy-1.15.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:795c46999bae845966368a3c013e0e00947932d68e235702b5c3f6ea799aa8c9", size = 37244796, upload-time = "2025-05-08T16:05:48.119Z" }, + { url = "https://files.pythonhosted.org/packages/10/c0/4f5f3eeccc235632aab79b27a74a9130c6c35df358129f7ac8b29f562ac7/scipy-1.15.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:18aaacb735ab38b38db42cb01f6b92a2d0d4b6aabefeb07f02849e47f8fb3594", size = 40047684, upload-time = "2025-05-08T16:05:54.22Z" }, + { url = "https://files.pythonhosted.org/packages/ab/a7/0ddaf514ce8a8714f6ed243a2b391b41dbb65251affe21ee3077ec45ea9a/scipy-1.15.3-cp311-cp311-win_amd64.whl", hash = "sha256:ae48a786a28412d744c62fd7816a4118ef97e5be0bee968ce8f0a2fba7acf3bb", size = 41246504, upload-time = "2025-05-08T16:06:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/37/4b/683aa044c4162e10ed7a7ea30527f2cbd92e6999c10a8ed8edb253836e9c/scipy-1.15.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6ac6310fdbfb7aa6612408bd2f07295bcbd3fda00d2d702178434751fe48e019", size = 38766735, upload-time = "2025-05-08T16:06:06.471Z" }, + { url = "https://files.pythonhosted.org/packages/7b/7e/f30be3d03de07f25dc0ec926d1681fed5c732d759ac8f51079708c79e680/scipy-1.15.3-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:185cd3d6d05ca4b44a8f1595af87f9c372bb6acf9c808e99aa3e9aa03bd98cf6", size = 30173284, upload-time = "2025-05-08T16:06:11.686Z" }, + { url = "https://files.pythonhosted.org/packages/07/9c/0ddb0d0abdabe0d181c1793db51f02cd59e4901da6f9f7848e1f96759f0d/scipy-1.15.3-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:05dc6abcd105e1a29f95eada46d4a3f251743cfd7d3ae8ddb4088047f24ea477", size = 22446958, upload-time = "2025-05-08T16:06:15.97Z" }, + { url = "https://files.pythonhosted.org/packages/af/43/0bce905a965f36c58ff80d8bea33f1f9351b05fad4beaad4eae34699b7a1/scipy-1.15.3-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:06efcba926324df1696931a57a176c80848ccd67ce6ad020c810736bfd58eb1c", size = 25242454, upload-time = "2025-05-08T16:06:20.394Z" }, + { url = "https://files.pythonhosted.org/packages/56/30/a6f08f84ee5b7b28b4c597aca4cbe545535c39fe911845a96414700b64ba/scipy-1.15.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c05045d8b9bfd807ee1b9f38761993297b10b245f012b11b13b91ba8945f7e45", size = 35210199, upload-time = "2025-05-08T16:06:26.159Z" }, + { url = "https://files.pythonhosted.org/packages/0b/1f/03f52c282437a168ee2c7c14a1a0d0781a9a4a8962d84ac05c06b4c5b555/scipy-1.15.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:271e3713e645149ea5ea3e97b57fdab61ce61333f97cfae392c28ba786f9bb49", size = 37309455, upload-time = "2025-05-08T16:06:32.778Z" }, + { url = "https://files.pythonhosted.org/packages/89/b1/fbb53137f42c4bf630b1ffdfc2151a62d1d1b903b249f030d2b1c0280af8/scipy-1.15.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6cfd56fc1a8e53f6e89ba3a7a7251f7396412d655bca2aa5611c8ec9a6784a1e", size = 36885140, upload-time = "2025-05-08T16:06:39.249Z" }, + { url = "https://files.pythonhosted.org/packages/2e/2e/025e39e339f5090df1ff266d021892694dbb7e63568edcfe43f892fa381d/scipy-1.15.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0ff17c0bb1cb32952c09217d8d1eed9b53d1463e5f1dd6052c7857f83127d539", size = 39710549, upload-time = "2025-05-08T16:06:45.729Z" }, + { url = "https://files.pythonhosted.org/packages/e6/eb/3bf6ea8ab7f1503dca3a10df2e4b9c3f6b3316df07f6c0ded94b281c7101/scipy-1.15.3-cp312-cp312-win_amd64.whl", hash = "sha256:52092bc0472cfd17df49ff17e70624345efece4e1a12b23783a1ac59a1b728ed", size = 40966184, upload-time = "2025-05-08T16:06:52.623Z" }, +] + +[[package]] +name = "scipy" +version = "1.17.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'win32'", + "python_full_version >= '3.12' and sys_platform == 'emscripten'", + "python_full_version == '3.11.*' and sys_platform == 'emscripten'", + "python_full_version >= '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +dependencies = [ + { name = "numpy", version = "2.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/75/b4ce781849931fef6fd529afa6b63711d5a733065722d0c3e2724af9e40a/scipy-1.17.1-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:1f95b894f13729334fb990162e911c9e5dc1ab390c58aa6cbecb389c5b5e28ec", size = 31613675, upload-time = "2026-02-23T00:16:00.13Z" }, + { url = "https://files.pythonhosted.org/packages/f7/58/bccc2861b305abdd1b8663d6130c0b3d7cc22e8d86663edbc8401bfd40d4/scipy-1.17.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:e18f12c6b0bc5a592ed23d3f7b891f68fd7f8241d69b7883769eb5d5dfb52696", size = 28162057, upload-time = "2026-02-23T00:16:09.456Z" }, + { url = "https://files.pythonhosted.org/packages/6d/ee/18146b7757ed4976276b9c9819108adbc73c5aad636e5353e20746b73069/scipy-1.17.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:a3472cfbca0a54177d0faa68f697d8ba4c80bbdc19908c3465556d9f7efce9ee", size = 20334032, upload-time = "2026-02-23T00:16:17.358Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e6/cef1cf3557f0c54954198554a10016b6a03b2ec9e22a4e1df734936bd99c/scipy-1.17.1-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:766e0dc5a616d026a3a1cffa379af959671729083882f50307e18175797b3dfd", size = 22709533, upload-time = "2026-02-23T00:16:25.791Z" }, + { url = "https://files.pythonhosted.org/packages/4d/60/8804678875fc59362b0fb759ab3ecce1f09c10a735680318ac30da8cd76b/scipy-1.17.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:744b2bf3640d907b79f3fd7874efe432d1cf171ee721243e350f55234b4cec4c", size = 33062057, upload-time = "2026-02-23T00:16:36.931Z" }, + { url = "https://files.pythonhosted.org/packages/09/7d/af933f0f6e0767995b4e2d705a0665e454d1c19402aa7e895de3951ebb04/scipy-1.17.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43af8d1f3bea642559019edfe64e9b11192a8978efbd1539d7bc2aaa23d92de4", size = 35349300, upload-time = "2026-02-23T00:16:49.108Z" }, + { url = "https://files.pythonhosted.org/packages/b4/3d/7ccbbdcbb54c8fdc20d3b6930137c782a163fa626f0aef920349873421ba/scipy-1.17.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cd96a1898c0a47be4520327e01f874acfd61fb48a9420f8aa9f6483412ffa444", size = 35127333, upload-time = "2026-02-23T00:17:01.293Z" }, + { url = "https://files.pythonhosted.org/packages/e8/19/f926cb11c42b15ba08e3a71e376d816ac08614f769b4f47e06c3580c836a/scipy-1.17.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4eb6c25dd62ee8d5edf68a8e1c171dd71c292fdae95d8aeb3dd7d7de4c364082", size = 37741314, upload-time = "2026-02-23T00:17:12.576Z" }, + { url = "https://files.pythonhosted.org/packages/95/da/0d1df507cf574b3f224ccc3d45244c9a1d732c81dcb26b1e8a766ae271a8/scipy-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:d30e57c72013c2a4fe441c2fcb8e77b14e152ad48b5464858e07e2ad9fbfceff", size = 36607512, upload-time = "2026-02-23T00:17:23.424Z" }, + { url = "https://files.pythonhosted.org/packages/68/7f/bdd79ceaad24b671543ffe0ef61ed8e659440eb683b66f033454dcee90eb/scipy-1.17.1-cp311-cp311-win_arm64.whl", hash = "sha256:9ecb4efb1cd6e8c4afea0daa91a87fbddbce1b99d2895d151596716c0b2e859d", size = 24599248, upload-time = "2026-02-23T00:17:34.561Z" }, + { url = "https://files.pythonhosted.org/packages/35/48/b992b488d6f299dbe3f11a20b24d3dda3d46f1a635ede1c46b5b17a7b163/scipy-1.17.1-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:35c3a56d2ef83efc372eaec584314bd0ef2e2f0d2adb21c55e6ad5b344c0dcb8", size = 31610954, upload-time = "2026-02-23T00:17:49.855Z" }, + { url = "https://files.pythonhosted.org/packages/b2/02/cf107b01494c19dc100f1d0b7ac3cc08666e96ba2d64db7626066cee895e/scipy-1.17.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:fcb310ddb270a06114bb64bbe53c94926b943f5b7f0842194d585c65eb4edd76", size = 28172662, upload-time = "2026-02-23T00:18:01.64Z" }, + { url = "https://files.pythonhosted.org/packages/cf/a9/599c28631bad314d219cf9ffd40e985b24d603fc8a2f4ccc5ae8419a535b/scipy-1.17.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:cc90d2e9c7e5c7f1a482c9875007c095c3194b1cfedca3c2f3291cdc2bc7c086", size = 20344366, upload-time = "2026-02-23T00:18:12.015Z" }, + { url = "https://files.pythonhosted.org/packages/35/f5/906eda513271c8deb5af284e5ef0206d17a96239af79f9fa0aebfe0e36b4/scipy-1.17.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:c80be5ede8f3f8eded4eff73cc99a25c388ce98e555b17d31da05287015ffa5b", size = 22704017, upload-time = "2026-02-23T00:18:21.502Z" }, + { url = "https://files.pythonhosted.org/packages/da/34/16f10e3042d2f1d6b66e0428308ab52224b6a23049cb2f5c1756f713815f/scipy-1.17.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e19ebea31758fac5893a2ac360fedd00116cbb7628e650842a6691ba7ca28a21", size = 32927842, upload-time = "2026-02-23T00:18:35.367Z" }, + { url = "https://files.pythonhosted.org/packages/01/8e/1e35281b8ab6d5d72ebe9911edcdffa3f36b04ed9d51dec6dd140396e220/scipy-1.17.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:02ae3b274fde71c5e92ac4d54bc06c42d80e399fec704383dcd99b301df37458", size = 35235890, upload-time = "2026-02-23T00:18:49.188Z" }, + { url = "https://files.pythonhosted.org/packages/c5/5c/9d7f4c88bea6e0d5a4f1bc0506a53a00e9fcb198de372bfe4d3652cef482/scipy-1.17.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8a604bae87c6195d8b1045eddece0514d041604b14f2727bbc2b3020172045eb", size = 35003557, upload-time = "2026-02-23T00:18:54.74Z" }, + { url = "https://files.pythonhosted.org/packages/65/94/7698add8f276dbab7a9de9fb6b0e02fc13ee61d51c7c3f85ac28b65e1239/scipy-1.17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f590cd684941912d10becc07325a3eeb77886fe981415660d9265c4c418d0bea", size = 37625856, upload-time = "2026-02-23T00:19:00.307Z" }, + { url = "https://files.pythonhosted.org/packages/a2/84/dc08d77fbf3d87d3ee27f6a0c6dcce1de5829a64f2eae85a0ecc1f0daa73/scipy-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:41b71f4a3a4cab9d366cd9065b288efc4d4f3c0b37a91a8e0947fb5bd7f31d87", size = 36549682, upload-time = "2026-02-23T00:19:07.67Z" }, + { url = "https://files.pythonhosted.org/packages/bc/98/fe9ae9ffb3b54b62559f52dedaebe204b408db8109a8c66fdd04869e6424/scipy-1.17.1-cp312-cp312-win_arm64.whl", hash = "sha256:f4115102802df98b2b0db3cce5cb9b92572633a1197c77b7553e5203f284a5b3", size = 24547340, upload-time = "2026-02-23T00:19:12.024Z" }, +] + [[package]] name = "semantic-version" version = "2.10.0" @@ -2173,6 +2582,9 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5b/30/bfebdd8ec77db9a79775121789992d6b3b75ee5494971294d7b4b7c999bc/torch-2.10.0-2-cp310-none-macosx_11_0_arm64.whl", hash = "sha256:2b980edd8d7c0a68c4e951ee1856334a43193f98730d97408fbd148c1a933313", size = 79411457, upload-time = "2026-02-10T21:44:59.189Z" }, { url = "https://files.pythonhosted.org/packages/0f/8b/4b61d6e13f7108f36910df9ab4b58fd389cc2520d54d81b88660804aad99/torch-2.10.0-2-cp311-none-macosx_11_0_arm64.whl", hash = "sha256:418997cb02d0a0f1497cf6a09f63166f9f5df9f3e16c8a716ab76a72127c714f", size = 79423467, upload-time = "2026-02-10T21:44:48.711Z" }, { url = "https://files.pythonhosted.org/packages/d3/54/a2ba279afcca44bbd320d4e73675b282fcee3d81400ea1b53934efca6462/torch-2.10.0-2-cp312-none-macosx_11_0_arm64.whl", hash = "sha256:13ec4add8c3faaed8d13e0574f5cd4a323c11655546f91fbe6afa77b57423574", size = 79498202, upload-time = "2026-02-10T21:44:52.603Z" }, + { url = "https://files.pythonhosted.org/packages/16/ee/efbd56687be60ef9af0c9c0ebe106964c07400eade5b0af8902a1d8cd58c/torch-2.10.0-3-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:a1ff626b884f8c4e897c4c33782bdacdff842a165fee79817b1dd549fdda1321", size = 915510070, upload-time = "2026-03-11T14:16:39.386Z" }, + { url = "https://files.pythonhosted.org/packages/36/ab/7b562f1808d3f65414cd80a4f7d4bb00979d9355616c034c171249e1a303/torch-2.10.0-3-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:ac5bdcbb074384c66fa160c15b1ead77839e3fe7ed117d667249afce0acabfac", size = 915518691, upload-time = "2026-03-11T14:15:43.147Z" }, + { url = "https://files.pythonhosted.org/packages/b3/7a/abada41517ce0011775f0f4eacc79659bc9bc6c361e6bfe6f7052a6b9363/torch-2.10.0-3-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:98c01b8bb5e3240426dcde1446eed6f40c778091c8544767ef1168fc663a05a6", size = 915622781, upload-time = "2026-03-11T14:17:11.354Z" }, { url = "https://files.pythonhosted.org/packages/0c/1a/c61f36cfd446170ec27b3a4984f072fd06dab6b5d7ce27e11adb35d6c838/torch-2.10.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:5276fa790a666ee8becaffff8acb711922252521b28fbce5db7db5cf9cb2026d", size = 145992962, upload-time = "2026-01-21T16:24:14.04Z" }, { url = "https://files.pythonhosted.org/packages/b5/60/6662535354191e2d1555296045b63e4279e5a9dbad49acf55a5d38655a39/torch-2.10.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:aaf663927bcd490ae971469a624c322202a2a1e68936eb952535ca4cd3b90444", size = 915599237, upload-time = "2026-01-21T16:23:25.497Z" }, { url = "https://files.pythonhosted.org/packages/40/b8/66bbe96f0d79be2b5c697b2e0b187ed792a15c6c4b8904613454651db848/torch-2.10.0-cp310-cp310-win_amd64.whl", hash = "sha256:a4be6a2a190b32ff5c8002a0977a25ea60e64f7ba46b1be37093c141d9c49aeb", size = 113720931, upload-time = "2026-01-21T16:24:23.743Z" }, @@ -2262,6 +2674,7 @@ dependencies = [ { name = "torch" }, { name = "torchvision" }, { name = "typer" }, + { name = "ultralytics" }, { name = "uvicorn" }, ] @@ -2316,6 +2729,7 @@ requires-dist = [ { name = "torch", specifier = ">=2.5" }, { name = "torchvision", specifier = ">=0.20" }, { name = "typer", specifier = ">=0.12.3,<1" }, + { name = "ultralytics", specifier = ">=8.3" }, { name = "uvicorn", specifier = ">=0.20" }, ] provides-extras = ["gui", "postgres"] @@ -2399,6 +2813,45 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" }, ] +[[package]] +name = "ultralytics" +version = "8.4.37" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "matplotlib" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "opencv-python" }, + { name = "pillow" }, + { name = "polars" }, + { name = "psutil" }, + { name = "pyyaml" }, + { name = "requests" }, + { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "torch" }, + { name = "torchvision" }, + { name = "ultralytics-thop" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/52/87/cd2431b2f073bcc70804b56515b92712bb99e6b014f9565473c8eebb30e2/ultralytics-8.4.37.tar.gz", hash = "sha256:fcf710478f4d6baf60bdcd7677ba3e425ef0bc192ae93dec3ed59e318e5992a9", size = 1034685, upload-time = "2026-04-10T12:15:11.519Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/ed/a1f9b2ff3e8e1e6f5472a33e3184c2fde1437d9392f448b851ea58ab8bb7/ultralytics-8.4.37-py3-none-any.whl", hash = "sha256:64df0489bb301f6dbaa7377a07dd289e80247abe322c3431cc308b15dec86cbd", size = 1225775, upload-time = "2026-04-10T12:15:07.723Z" }, +] + +[[package]] +name = "ultralytics-thop" +version = "2.0.18" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "torch" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/63/21a32e1facfeee245dbdfb7b4669faf7a36ff7c00b50987932bdab126f4b/ultralytics_thop-2.0.18.tar.gz", hash = "sha256:21103bcd39cc9928477dc3d9374561749b66a1781b35f46256c8d8c4ac01d9cf", size = 34557, upload-time = "2025-10-29T16:58:13.526Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/c7/fb42228bb05473d248c110218ffb8b1ad2f76728ed8699856e5af21112ad/ultralytics_thop-2.0.18-py3-none-any.whl", hash = "sha256:2bb44851ad224b116c3995b02dd5e474a5ccf00acf237fe0edb9e1506ede04ec", size = 28941, upload-time = "2025-10-29T16:58:12.093Z" }, +] + [[package]] name = "urllib3" version = "2.6.3"