diff --git a/pyproject.toml b/pyproject.toml index 51bf7fd..2425f9c 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,8 @@ dependencies = [ "torch>=2.5", "torchvision>=0.20", "typer>=0.12.3,<1", + # Ultralytics is AGPL-3.0. YOLO26 support first ships in v8.4.0. + "ultralytics>=8.4.0", "uvicorn>=0.20", ] diff --git a/trapdata/antenna/worker.py b/trapdata/antenna/worker.py index 2b7e1db..04a7c83 100644 --- a/trapdata/antenna/worker.py +++ b/trapdata/antenna/worker.py @@ -14,10 +14,17 @@ 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.models.classification import MothClassifierBinary -from trapdata.api.models.localization import APIMothDetector +from trapdata.api.api import ( + PIPELINE_CHOICES, + PipelineDefinition, + run_classification_stages, +) +from trapdata.api.models.classification import APIMothClassifier +from trapdata.api.models.localization import APIAnyBugDetector, APIMothDetector from trapdata.api.schemas import ( + AlgorithmConfigResponse, + AlgorithmReference, + ClassificationResponse, DetectionResponse, PipelineResultsResponse, SourceImageResponse, @@ -29,6 +36,41 @@ MAX_PENDING_POSTS = 5 # Maximum number of concurrent result posts before blocking SLEEP_TIME_SECONDS = 5 +# Sentinel classification for a detection whose bounding box cannot be cropped — +# degenerate after clamping to the image (zero width/height, or a box entirely +# outside the frame). Tagging it keeps every returned detection carrying a +# classification, the pipeline's invariant, instead of a naked box. It is marked +# non-terminal and carries its own algorithm reference so the platform never +# mistakes it for a real stage result, mirroring how a gate tags the detections +# it drops. +UNCROPPABLE_LABEL = "uncroppable" +UNCROPPABLE_ALGORITHM = AlgorithmReference( + name="Uncroppable detection", key="uncroppable" +) + + +def _tag_uncroppable(detection: DetectionResponse) -> None: + """Attach the non-terminal ``uncroppable`` sentinel to a detection whose bbox + cannot yield a crop, so it is returned tagged rather than naked. Idempotent: + re-tagging replaces the prior sentinel instead of stacking duplicates. + """ + detection.classifications = [ + c + for c in detection.classifications + if c.algorithm.key != UNCROPPABLE_ALGORITHM.key + ] + detection.classifications.append( + ClassificationResponse( + classification=UNCROPPABLE_LABEL, + scores=[1.0], + logits=[0.0], + inference_time=0, + algorithm=UNCROPPABLE_ALGORITHM, + terminal=False, + timestamp=datetime.datetime.now(), + ) + ) + def run_worker(pipelines: list[str]): """Run the worker to process images from the REST API queue. @@ -129,87 +171,115 @@ def _worker_loop(gpu_id: int, pipelines: list[str]): time.sleep(SLEEP_TIME_SECONDS) -def _apply_binary_classification( - binary_filter: "MothClassifierBinary", - detector_results: list[DetectionResponse], - image_tensors: dict[str, torch.Tensor], - image_detections: dict[str, list[DetectionResponse]], -) -> tuple[list[DetectionResponse], list[DetectionResponse]]: - """Apply binary classification to filter moth vs non-moth detections. +def _build_stage_models( + pipeline_def: PipelineDefinition, +) -> dict[type[APIMothClassifier], APIMothClassifier]: + """Instantiate every classification stage of ``pipeline_def`` once so the + per-batch loop reuses the loaded weights instead of reloading a model for + each batch. Intermediate gates are built non-terminal and the terminal + classifier terminal, matching the labels the /process path assigns. - Args: - binary_filter: The binary classifier instance - detector_results: List of detections from the object detector - image_tensors: Mapping of image IDs to tensor data - image_detections: Mapping to store detections by image ID + Keyed by classifier class. The registry never uses one classifier as both a + gate and the terminal, so there is no key collision. + """ + stage_models: dict[type[APIMothClassifier], APIMothClassifier] = {} + for stage in pipeline_def.intermediates: + stage_models[stage.classifier] = stage.classifier( + source_images=[], detections=[], terminal=False + ) + stage_models[pipeline_def.terminal] = pipeline_def.terminal( + source_images=[], detections=[], terminal=True + ) + return stage_models - Returns: - Tuple of (moth_detections, non_moth_detections) + +def _classify_detections_from_tensors( + stage: APIMothClassifier, + detections: list[DetectionResponse], + image_tensors: dict[str, torch.Tensor], +) -> APIMothClassifier: + """Run one classifier stage over crops taken from the already-loaded image + tensors, annotating each detection in place, and return the stage with its + ``results`` set to every detection it was handed. + + This is the worker-side counterpart to ``APIMothClassifier.run()``: rather + than re-reading images by URL it slices each crop from the in-memory tensors + the dataloader already produced. It honors the same contract the /process + path relies on — ``results`` is the full input list (annotated wherever the + crop was readable), keyed on detection identity — so a detection whose crop + cannot be read keeps its own label instead of shifting its neighbours'. One + function now runs every gate and the terminal, replacing the old binary-only + crop loop, so the sync and async paths share one stage engine. + + A detection whose bbox is degenerate after clamping to the image (zero-area + or entirely off-frame) is tagged with the non-terminal ``uncroppable`` + sentinel (see :func:`_tag_uncroppable`) rather than left un-annotated, so + every returned detection carries a classification. """ - binary_filter.reset(detector_results) - - # Process binary classification crops - binary_crops = [] - binary_valid_indices = [] - binary_transforms = binary_filter.get_transforms() - - for idx, dresp in enumerate(detector_results): - 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) + stage.reset(detections) + transforms = stage.get_transforms() + + crops = [] + valid_indices = [] + for idx, detection in enumerate(detections): + image_tensor = image_tensors[detection.source_image_id] + height, width = int(image_tensor.shape[-2]), int(image_tensor.shape[-1]) + # Clamp to image bounds so this tensor-slicing runner and the /process + # PIL-crop runner see the SAME in-bounds region (see + # BoundingBox.clamp_to_bounds). Without it, tensor slicing wraps a + # negative coordinate while PIL zero-pads, diverging the two paths. + x1, y1, x2, y2 = detection.bbox.clamp_to_bounds(width, height) if y1 >= y2 or x1 >= x2: + # Degenerate after clamping (zero-area or entirely off-image). Tag it + # uncroppable so it is returned carrying a classification rather than + # naked, then skip the crop. logger.warning( - f"Skipping binary classification {idx} with invalid bbox: " - f"({x1},{y1})->({x2},{y2})" + f"Tagging {stage.name} detection {idx} uncroppable; bbox is " + f"degenerate after clamping to the image: ({x1},{y1})->({x2},{y2})" ) + _tag_uncroppable(detection) continue crop = image_tensor[:, y1:y2, x1:x2] - crop_transformed = binary_transforms(crop) - binary_crops.append(crop_transformed) - binary_valid_indices.append(idx) - - moth_detections = [] - non_moth_detections = [] - - if binary_crops: - batched_binary_crops = torch.stack(binary_crops) - binary_out = binary_filter.predict_batch(batched_binary_crops) - binary_out = binary_filter.post_process_batch(binary_out) - - for crop_i, idx in enumerate(binary_valid_indices): - dresp = detector_results[idx] - detection = binary_filter.update_detection_classification( + crops.append(transforms(crop)) + valid_indices.append(idx) + + if crops: + batched_crops = torch.stack(crops) + stage_out = stage.predict_batch(batched_crops) + stage_out = stage.post_process_batch(stage_out) + for crop_i, idx in enumerate(valid_indices): + detection = detections[idx] + stage.update_detection_classification( seconds_per_item=0, - image_id=dresp.source_image_id, + image_id=detection.source_image_id, detection_idx=idx, - predictions=binary_out[crop_i], + predictions=stage_out[crop_i], ) - # Separate moth from non-moth detections - for classification in detection.classifications: - if classification.classification == binary_filter.positive_binary_label: - moth_detections.append(detection) - elif ( - classification.classification == binary_filter.negative_binary_label - ): - non_moth_detections.append(detection) - image_detections[detection.source_image_id].append(detection) - break - - return moth_detections, non_moth_detections + # Identity-keyed contract shared with the /process path: return every + # detection handed in, annotated in place where the crop was readable, in + # the same order. + stage.results = detections + return stage def _process_batch( batch: dict, batch_num: int, - detector: APIMothDetector, - classifier, + detector: APIMothDetector | APIAnyBugDetector, + stage_models: dict[type[APIMothClassifier], APIMothClassifier], + pipeline_def: PipelineDefinition, pipeline: str, - binary_filter: "MothClassifierBinary | None", - use_binary_filter: bool, ) -> tuple[int, int, list[AntennaTaskResult], float, float]: - """Process a single batch of images through detection and classification. + """Process a single batch of images through the pipeline's stages. + + The inference core is stage-driven: the detector comes from + ``pipeline_def.detector`` (YOLO26 or FasterRCNN, whichever the pipeline + declares) and the intermediate gate(s) and terminal classifier run through + the SAME ``run_classification_stages`` engine as the FastAPI /process path, + so the two code paths cannot drift. Only the inference mechanism is worker- + specific: crops are sliced from the already-loaded image tensors instead of + re-read from URLs. All large intermediates (image_tensors, crops, batched_crops, image_detections) are local to this function and freed by Python's reference counting when it @@ -218,11 +288,11 @@ def _process_batch( Args: batch: Dictionary with images, image_ids, reply_subjects, image_urls, failed_items batch_num: 0-based batch index (for logging) - detector: APIMothDetector instance (reset before call) - classifier: Terminal species classifier instance + detector: The pipeline's detector instance (reset before call) + stage_models: Preloaded classifier stage instances keyed by class, one per + intermediate gate plus the terminal classifier (see _build_stage_models) + pipeline_def: The pipeline's stage definition driving detection/classification pipeline: Pipeline slug for response payload - binary_filter: Binary moth/non-moth classifier, or None - use_binary_filter: Whether to run binary classification step Returns: (n_items, n_detections, batch_results, detect_time, classify_time) @@ -268,74 +338,47 @@ def _process_batch( ) detect_time = (datetime.datetime.now() - batch_start_time).total_seconds() - # Group detections by image_id - image_detections: dict[str, list[DetectionResponse]] = { - img_id: [] for img_id in image_ids - } image_tensors = dict(zip(image_ids, images, strict=True)) - # Apply binary classification filter if needed - detector_results = detector.results - - if use_binary_filter: - assert binary_filter is not None, "Binary filter not initialized" - ( - detections_for_terminal_classifier, - detections_to_return, - ) = _apply_binary_classification( - binary_filter, - detector_results, - image_tensors, - image_detections, - ) - else: - detections_for_terminal_classifier = detector_results - detections_to_return = [] - - # Run terminal classifier on filtered detections - classifier.reset(detections_for_terminal_classifier) - classify_transforms = classifier.get_transforms() - - # Collect and transform all crops for batched classification - crops = [] - valid_indices = [] - n_detections = 0 - 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) - if y1 >= y2 or x1 >= x2: - logger.warning( - f"Skipping detection {idx} with invalid bbox: " - f"({x1},{y1})->({x2},{y2})" - ) - continue - crop = image_tensor[:, y1:y2, x1:x2] - crop_transformed = classify_transforms(crop) - crops.append(crop_transformed) - valid_indices.append(idx) + # --- Classification stages --- + # Run the pipeline's intermediate gate(s) and terminal classifier through + # the SAME engine as the /process path, so the two cannot drift. Each + # stage classifies crops sliced from the already-loaded image tensors + # (run_stage below) rather than re-reading images by URL. + algorithms_used: dict[str, AlgorithmConfigResponse] = {} + + def run_stage( + classifier_class: type[APIMothClassifier], + detections: list[DetectionResponse], + terminal: bool, + ) -> APIMothClassifier: + stage = stage_models[classifier_class] + stage.terminal = terminal + return _classify_detections_from_tensors(stage, detections, image_tensors) classify_start = datetime.datetime.now() - if crops: - batched_crops = torch.stack(crops) - classifier_out = classifier.predict_batch(batched_crops) - classifier_out = classifier.post_process_batch(classifier_out) - - for crop_i, idx in enumerate(valid_indices): - dresp = detections_for_terminal_classifier[idx] - detection = classifier.update_detection_classification( - seconds_per_item=0, - image_id=dresp.source_image_id, - detection_idx=idx, - predictions=classifier_out[crop_i], - ) - image_detections[dresp.source_image_id].append(detection) - n_detections += 1 - + detections_to_return = run_classification_stages( + pipeline=pipeline_def, + source_images=[], + detector_results=detector.results, + example_config_param=None, + algorithms_used=algorithms_used, + run_stage=run_stage, + ) classify_time = (datetime.datetime.now() - classify_start).total_seconds() - # Count non-moth detections returned from binary filter - n_detections += len(detections_to_return) + + # Group every returned detection (gate-dropped and tagged, plus terminal- + # classified) by image for posting. Algorithm provenance travels on each + # detection's own classifications, so no separate tracking is needed. + image_detections: dict[str, list[DetectionResponse]] = { + img_id: [] for img_id in image_ids + } + for detection in detections_to_return: + image_detections[detection.source_image_id].append(detection) + n_detections = len(detections_to_return) + logger.debug( + f"Batch {batch_num + 1} stages used: {list(algorithms_used.keys())}" + ) # Calculate batch processing time batch_end_time = datetime.datetime.now() @@ -421,13 +464,18 @@ def _process_job( job_id=job_id, settings=settings, ) - classifier = None - detector = None - - # Check if binary filtering is needed once for the entire job - classifier_class = CLASSIFIER_CHOICES[pipeline] - use_binary_filter = should_filter_detections(classifier_class) - binary_filter = None + detector: APIMothDetector | APIAnyBugDetector | None = None + stage_models: dict[type[APIMothClassifier], APIMothClassifier] = {} + + # Look the pipeline up in the full registry (the same one /info advertises + # and the worker subscribes to) so every advertised slug is dispatchable. + # Every stage — detector, gate(s), terminal — is taken from this definition + # and executed by the shared stage engine, so the worker runs whatever the + # pipeline declares (YOLO26 + Lepidoptera order gate for anybug, FasterRCNN + # + binary moth gate for the legacy pipelines) with no per-pipeline branching + # here. No capability guard is needed: any pipeline the registry advertises + # is composed of stages this path can run. + pipeline_def = PIPELINE_CHOICES[pipeline] if device is None: device = torch.device("cuda" if torch.cuda.is_available() else "cpu") @@ -461,25 +509,17 @@ def _process_job( logger.warning(f"Batch {i + 1} is empty, skipping") continue - # Defer instantiation of poster, detector and classifiers until we have data - if not classifier: - classifier = classifier_class(source_images=[], detections=[]) - detector = APIMothDetector([]) + # Defer instantiation of poster, detector and classifier stages until + # we have data, so an empty job loads no model weights. Every stage + # (detector, gate(s), terminal) comes from the pipeline definition and + # is loaded once here, then reused across batches. + if detector is None: + detector = pipeline_def.detector(source_images=[]) + stage_models = _build_stage_models(pipeline_def) result_poster = ResultPoster(max_pending=MAX_PENDING_POSTS) - if use_binary_filter: - binary_filter = MothClassifierBinary( - source_images=[], - detections=[], - terminal=False, - ) - assert detector is not None, "Detector not initialized" - assert classifier is not None, "Classifier not initialized" assert result_poster is not None, "ResultPoster not initialized" - assert not ( - use_binary_filter and binary_filter is None - ), "Binary filter not initialized" detector.reset([]) did_work = True @@ -487,10 +527,9 @@ def _process_job( batch, i, detector, - classifier, + stage_models, + pipeline_def, pipeline, - binary_filter, - use_binary_filter, ) items += n_items total_detections += n_detections diff --git a/trapdata/api/api.py b/trapdata/api/api.py index 47f34fe..e1ac77b 100644 --- a/trapdata/api/api.py +++ b/trapdata/api/api.py @@ -3,8 +3,10 @@ pipelines. """ +import dataclasses import enum import time +from collections.abc import Callable from contextlib import asynccontextmanager import fastapi @@ -26,7 +28,7 @@ MothClassifierTuringKenyaUganda, MothClassifierUKDenmark, ) -from .models.localization import APIMothDetector +from .models.localization import APIAnyBugDetector, APIMothDetector from .schemas import ( AlgorithmCategoryMapResponse, AlgorithmConfigResponse, @@ -52,31 +54,197 @@ async def lifespan(app: fastapi.FastAPI): app.add_middleware(GZipMiddleware) -CLASSIFIER_CHOICES = { - "panama_moths_2023": MothClassifierPanama, - "panama_moths_2024": MothClassifierPanama2024, - "quebec_vermont_moths_2023": MothClassifierQuebecVermont, - "uk_denmark_moths_2023": MothClassifierUKDenmark, - "costa_rica_moths_turing_2024": MothClassifierTuringCostaRica, - "anguilla_moths_turing_2024": MothClassifierTuringAnguilla, - "kenya-uganda_moths_turing_2024": MothClassifierTuringKenyaUganda, - "global_moths_2024": MothClassifierGlobal, - "moth_binary": MothClassifierBinary, - "insect_orders_2025": InsectOrderClassifier, -} -_classifier_choices = dict( - zip(CLASSIFIER_CHOICES.keys(), list(CLASSIFIER_CHOICES.keys())) +# Confirmed from the insect_orders_2025 category map +# (ami-models/insect_orders/insect_order_category_map.json): the Lepidoptera +# order is labelled exactly "Lepidoptera" (index 0). The order gate below +# matches this string against each detection's top predicted order. +LEPIDOPTERA_ORDER_LABEL = "Lepidoptera" + + +def _is_model_class(obj: object, task_type: str) -> bool: + """True when ``obj`` is a model class whose ``task_type`` matches, e.g. + "localization" for a detector or "classification" for a classifier. Used to + validate that each pipeline stage is filled with a model of the right role. + """ + return isinstance(obj, type) and getattr(obj, "task_type", None) == task_type + + +@dataclasses.dataclass(frozen=True) +class IntermediateStage: + """An intermediate classifier that gates detections between the detector and + the terminal classifier. + + Only detections whose top predicted label (from this classifier) is in + ``pass_labels`` are forwarded to the next stage. Non-passing detections are + returned to the caller tagged with this classifier's prediction. When + ``pass_labels`` is ``None`` every detection passes and the stage only tags. + """ + + classifier: type[APIMothClassifier] + pass_labels: tuple[str, ...] | None = None + + def __post_init__(self) -> None: + # Validate the slot's role on construction rather than trusting callers: + # a gate must be a classification model, and pass_labels must be a tuple + # (or None to mean "tag every detection and let all of them pass"). + if not _is_model_class(self.classifier, "classification"): + raise TypeError( + "IntermediateStage.classifier must be a classification model " + f"class, got {self.classifier!r}" + ) + if self.pass_labels is not None and not isinstance(self.pass_labels, tuple): + raise TypeError( + "IntermediateStage.pass_labels must be a tuple of labels or None, " + f"got {self.pass_labels!r}" + ) + + +@dataclasses.dataclass(frozen=True) +class PipelineDefinition: + """Ordered stage definition for a pipeline: one detector, zero or more + intermediate gate classifiers, then one terminal classifier. This is the + single source of truth for how a pipeline slug is processed and advertised. + + The fields are named by the role each stage plays (detector / intermediate + gate / terminal), and the shape is validated on construction: exactly one + localization model in ``detector``, exactly one classification model in + ``terminal``, and every ``intermediates`` entry an ``IntermediateStage``. + Because the detector and terminal are distinct single fields, "one detector + first, one terminal last" is enforced structurally, never by list position. + + ``name`` and ``description`` are what the platform shows an operator choosing + a pipeline. Set them whenever two pipelines share a terminal classifier: the + advertised name falls back to the terminal classifier's, so without them two + such pipelines are indistinguishable in the picker. + """ + + detector: type[APIMothDetector | APIAnyBugDetector] + terminal: type[APIMothClassifier] + intermediates: tuple[IntermediateStage, ...] = () + name: str | None = None + description: str | None = None + + def __post_init__(self) -> None: + if not _is_model_class(self.detector, "localization"): + raise TypeError( + "PipelineDefinition.detector must be a localization model class, " + f"got {self.detector!r}" + ) + if not _is_model_class(self.terminal, "classification"): + raise TypeError( + "PipelineDefinition.terminal must be a classification model class, " + f"got {self.terminal!r}" + ) + if not isinstance(self.intermediates, tuple) or not all( + isinstance(stage, IntermediateStage) for stage in self.intermediates + ): + raise TypeError( + "PipelineDefinition.intermediates must be a tuple of " + f"IntermediateStage, got {self.intermediates!r}" + ) + + +# The binary moth/non-moth filter used by every species pipeline: only "moth" +# detections proceed; everything else is returned tagged non-moth. +BINARY_MOTH_FILTER = IntermediateStage( + classifier=MothClassifierBinary, + pass_labels=(MothClassifierBinary.positive_binary_label,), +) + +# The insect-order gate used by the anybug pipeline: only detections whose top +# predicted order is Lepidoptera proceed to the species classifier; detections +# of other orders are returned tagged with their order. +LEPIDOPTERA_ORDER_GATE = IntermediateStage( + classifier=InsectOrderClassifier, + pass_labels=(LEPIDOPTERA_ORDER_LABEL,), ) -PipelineChoice = enum.Enum("PipelineChoice", _classifier_choices) +PIPELINE_CHOICES: dict[str, PipelineDefinition] = { + "panama_moths_2023": PipelineDefinition( + APIMothDetector, MothClassifierPanama, (BINARY_MOTH_FILTER,) + ), + "panama_moths_2024": PipelineDefinition( + APIMothDetector, MothClassifierPanama2024, (BINARY_MOTH_FILTER,) + ), + "quebec_vermont_moths_2023": PipelineDefinition( + APIMothDetector, MothClassifierQuebecVermont, (BINARY_MOTH_FILTER,) + ), + "uk_denmark_moths_2023": PipelineDefinition( + APIMothDetector, MothClassifierUKDenmark, (BINARY_MOTH_FILTER,) + ), + "costa_rica_moths_turing_2024": PipelineDefinition( + APIMothDetector, MothClassifierTuringCostaRica, (BINARY_MOTH_FILTER,) + ), + "anguilla_moths_turing_2024": PipelineDefinition( + APIMothDetector, MothClassifierTuringAnguilla, (BINARY_MOTH_FILTER,) + ), + "kenya-uganda_moths_turing_2024": PipelineDefinition( + APIMothDetector, MothClassifierTuringKenyaUganda, (BINARY_MOTH_FILTER,) + ), + "global_moths_2024": PipelineDefinition( + APIMothDetector, MothClassifierGlobal, (BINARY_MOTH_FILTER,) + ), + # The binary and order classifiers are themselves terminal, with no upstream + # gate: the stage list is the single source of truth for whether a binary + # filter runs. + "moth_binary": PipelineDefinition(APIMothDetector, MothClassifierBinary), + "insect_orders_2025": PipelineDefinition(APIMothDetector, InsectOrderClassifier), + # YOLO26 "any-bug" detector -> Lepidoptera order gate -> global species. + # This pipeline shares its terminal classifier with "global_moths_2024", so it + # must set its own name; otherwise both advertise the terminal classifier's + # name and an operator sees two identical entries. + "anybug_global_moths_2024": PipelineDefinition( + detector=APIAnyBugDetector, + terminal=MothClassifierGlobal, + intermediates=(LEPIDOPTERA_ORDER_GATE,), + name="Global moths with Anybug detector", + description=( + "Detects any insect with the YOLO26 'any-bug' detector, keeps the " + "detections whose predicted order is Lepidoptera, and classifies " + "those to species with the global moth model. Detections of other " + "orders are returned tagged with the order that was predicted." + ), + ), +} +_pipeline_choices = dict(zip(PIPELINE_CHOICES.keys(), list(PIPELINE_CHOICES.keys()))) + + +PipelineChoice = enum.Enum("PipelineChoice", _pipeline_choices) + + +# DEPRECATED backward-compatibility projections of PIPELINE_CHOICES, kept for +# call sites not yet migrated off the old shapes: registration, the api/tests +# helpers, and cli/base.py still import CLASSIFIER_CHOICES as a +# {slug: terminal_classifier} mapping. Both are DERIVED from PIPELINE_CHOICES — +# never hand-maintained in parallel — so the two views cannot drift. +# +# CLASSIFIER_CHOICES intentionally covers only the default-detector +# (APIMothDetector) pipelines, matching its historical contract. The anybug +# pipeline uses APIAnyBugDetector and so is absent here; consumers that must see +# every pipeline (the worker's subscription, validation, and dispatch) read +# PIPELINE_CHOICES directly. +# +# TODO(anybug): migrate the remaining CLASSIFIER_CHOICES consumers onto +# PIPELINE_CHOICES stage definitions, then delete this shim. +CLASSIFIER_CHOICES: dict[str, type[APIMothClassifier]] = { + slug: pipeline.terminal + for slug, pipeline in PIPELINE_CHOICES.items() + if pipeline.detector is APIMothDetector +} -def should_filter_detections(Classifier: type[APIMothClassifier]) -> bool: - if Classifier in [MothClassifierBinary, InsectOrderClassifier]: - return False - else: - return True +def top_label_for_algorithm( + detection: DetectionResponse, algorithm_key: str +) -> str | None: + """Return the top predicted label assigned to ``detection`` by the algorithm + with ``algorithm_key``, or ``None`` if that algorithm did not classify it. + Used by the intermediate gates to decide which detections proceed. + """ + for classification in detection.classifications: + if classification.algorithm.key == algorithm_key: + return classification.classification + return None def make_category_map_response( @@ -129,28 +297,28 @@ def make_algorithm_config_response( def make_pipeline_config_response( - Classifier: type[APIMothClassifier], + pipeline: PipelineDefinition, slug: str, ) -> PipelineConfigResponse: """ - Create a configuration for an entire pipeline, given a species classifier class. + Create a configuration for an entire pipeline by iterating its configured + stages: detector, any intermediate gate classifiers, then the terminal + classifier. + + The advertised name and description come from the pipeline when it sets them + and from the terminal classifier otherwise, so that pipelines sharing a + terminal classifier can still be told apart in the platform's picker. """ algorithms = [] - detector = APIMothDetector( - source_images=[], - ) + detector = pipeline.detector(source_images=[]) algorithms.append(make_algorithm_config_response(detector)) - if should_filter_detections(Classifier): - binary_classifier = MothClassifierBinary( - source_images=[], - detections=[], - terminal=False, - ) - algorithms.append(make_algorithm_config_response(binary_classifier)) + for stage in pipeline.intermediates: + gate = stage.classifier(source_images=[], detections=[], terminal=False) + algorithms.append(make_algorithm_config_response(gate)) - classifier = Classifier( + classifier = pipeline.terminal( source_images=[], detections=[], batch_size=settings.classification_batch_size, @@ -160,18 +328,144 @@ def make_pipeline_config_response( algorithms.append(make_algorithm_config_response(classifier)) return PipelineConfigResponse( - name=classifier.name, + name=pipeline.name or classifier.name, slug=slug, - description=classifier.description, + description=pipeline.description or classifier.description, version=1, algorithms=algorithms, ) +# How one classifier stage (an intermediate gate or the terminal classifier) is +# executed on the detections it is handed. Given the stage's class, its +# detections, and whether it is the terminal stage, the runner returns the stage +# instance with its predictions attached to those detections and its ``results`` +# set to every detection it was handed. +# +# Parameterizing this is what lets the FastAPI /process path and the in-process +# worker (antenna/worker.py) share the stage-orchestration engine below while +# each supplies its own inference mechanism, so the two paths cannot drift. +# /process constructs a fresh model and reads crops from image URLs; the worker +# reuses a preloaded model and slices crops from image tensors already in memory. +StageRunner = Callable[ + [type[APIMothClassifier], list[DetectionResponse], bool], APIMothClassifier +] + + +def _make_default_stage_runner( + source_images: list[SourceImage], + example_config_param: int | None, +) -> StageRunner: + """The /process stage runner: construct each stage fresh and run it through + its own URL-reading dataset. Only the terminal classifier receives + ``example_config_param``, matching the request-config contract. + """ + + def run_stage( + classifier_class: type[APIMothClassifier], + detections: list[DetectionResponse], + terminal: bool, + ) -> APIMothClassifier: + kwargs: dict = { + "source_images": source_images, + "detections": detections, + "batch_size": settings.classification_batch_size, + "num_workers": settings.num_workers, + # "single": True if len(detections) == 1 else False, + # @TODO solve issues with reading images in multiprocessing + "single": True, + "terminal": terminal, + } + if terminal: + kwargs["example_config_param"] = example_config_param + stage = classifier_class(**kwargs) + stage.run() + return stage + + return run_stage + + +def run_classification_stages( + pipeline: PipelineDefinition, + source_images: list[SourceImage], + detector_results: list[DetectionResponse], + example_config_param: int | None, + algorithms_used: dict[str, AlgorithmConfigResponse], + run_stage: StageRunner | None = None, +) -> list[DetectionResponse]: + """Run each intermediate gate, then the terminal classifier, and return every + detection exactly once. + + This is the shared classification engine for both the FastAPI /process path + and the in-process worker. Only *how* one stage runs its inference differs + between them, so that step is delegated to ``run_stage``; the stage order, + the pass/fail gate split, algorithm tracking, and the final assembly all live + here, once, so the two paths cannot drift. When ``run_stage`` is omitted the + /process behavior (construct fresh, read crops from URLs) is used. + + A detection that fails a gate is returned tagged with that gate's prediction + (marked non-terminal); a detection that passes every gate is returned tagged + additionally with the terminal classifier's prediction (marked terminal), so + the platform never shows a gate label as a passing detection's best result. + + The assembly is keyed on detection IDENTITY, never on list position: every + stage annotates the ``DetectionResponse`` objects it is handed in place, and + the passing/non-passing split routes each object by its own top gate label. + A detection a gate drops, or one whose crop the terminal classifier cannot + read, therefore keeps its own label instead of shifting the labels of the + detections that follow it. ``algorithms_used`` is populated in place. + """ + if run_stage is None: + run_stage = _make_default_stage_runner(source_images, example_config_param) + + num_pre_filter = len(detector_results) + detections_to_return: list[DetectionResponse] = [] + # Detections that survive every gate so far and continue to the next stage. + detections_for_next_stage: list[DetectionResponse] = detector_results + + for stage in pipeline.intermediates: + gate = run_stage(stage.classifier, detections_for_next_stage, False) + algorithms_used[gate.get_key()] = make_algorithm_response(gate) + + if stage.pass_labels is None: + # Tag-only stage: annotate the detections but let all of them pass. + detections_for_next_stage = gate.results + continue + + # Keep detections whose top predicted label (from this gate) is one of + # the pass labels; return the rest tagged with this gate's prediction. + passing: list[DetectionResponse] = [] + non_passing: list[DetectionResponse] = [] + for detection in gate.results: + top_label = top_label_for_algorithm(detection, gate.get_key()) + if top_label in stage.pass_labels: + passing.append(detection) + else: + non_passing.append(detection) + logger.info( + f"{gate.name}: {len(passing)} of {len(gate.results)} detections " + f"passed the {stage.pass_labels} gate" + ) + detections_to_return += non_passing + detections_for_next_stage = passing + + logger.info( + f"Sending {len(detections_for_next_stage)} of {num_pre_filter} " + "detections to the terminal classifier" + ) + + classifier = run_stage(pipeline.terminal, detections_for_next_stage, True) + algorithms_used[classifier.get_key()] = make_algorithm_response(classifier) + + # Return all detections, including those a gate filtered out upstream. + detections_to_return += classifier.results + return detections_to_return + + class PipelineRequest(PipelineRequest_): pipeline: PipelineChoice = pydantic.Field( description=PipelineRequest_.model_fields["pipeline"].description, - examples=list(_classifier_choices.keys()), + examples=list(_pipeline_choices.keys()), ) @@ -179,7 +473,7 @@ class PipelineResponse(PipelineResponse_): pipeline: PipelineChoice = pydantic.Field( PipelineChoice, description=PipelineResponse_.model_fields["pipeline"].description, - examples=list(_classifier_choices.keys()), + examples=list(_pipeline_choices.keys()), ) @@ -216,9 +510,9 @@ async def process(data: PipelineRequest) -> PipelineResponse: start_time = time.time() - Classifier = CLASSIFIER_CHOICES[str(data.pipeline)] + pipeline = PIPELINE_CHOICES[str(data.pipeline)] - detector = APIMothDetector( + detector = pipeline.detector( source_images=source_images, batch_size=settings.localization_batch_size, num_workers=settings.num_workers, @@ -226,71 +520,17 @@ async def process(data: PipelineRequest) -> PipelineResponse: single=True, # @TODO solve issues with reading images in multiprocessing ) detector_results = detector.run() - num_pre_filter = len(detector_results) algorithms_used[detector.get_key()] = make_algorithm_response(detector) - detections_for_terminal_classifier: list[DetectionResponse] = [] - detections_to_return: list[DetectionResponse] = [] - - if should_filter_detections(Classifier): - filter = MothClassifierBinary( - source_images=source_images, - detections=detector_results, - batch_size=settings.classification_batch_size, - num_workers=settings.num_workers, - # single=True if len(detector_results) == 1 else False, - single=True, # @TODO solve issues with reading images in multiprocessing - terminal=False, - ) - filter.run() - algorithms_used[filter.get_key()] = make_algorithm_response(filter) - - # Compare num detections with num moth detections - num_post_filter = len(filter.results) - logger.info( - f"Binary classifier returned {num_post_filter} of {num_pre_filter} detections" - ) - - # Filter results based on positive_binary_label - moth_detections = [] - non_moth_detections = [] - for detection in filter.results: - for classification in detection.classifications: - if classification.classification == filter.positive_binary_label: - moth_detections.append(detection) - elif classification.classification == filter.negative_binary_label: - non_moth_detections.append(detection) - break - detections_for_terminal_classifier += moth_detections - detections_to_return += non_moth_detections - - else: - logger.info("Skipping binary classification filter") - detections_for_terminal_classifier += detector_results - - logger.info( - f"Sending {len(detections_for_terminal_classifier)} of {num_pre_filter} " - "detections to the classifier" - ) - - classifier: APIMothClassifier = Classifier( + detections_to_return = run_classification_stages( + pipeline=pipeline, source_images=source_images, - detections=detections_for_terminal_classifier, - batch_size=settings.classification_batch_size, - num_workers=settings.num_workers, - # single=True if len(filtered_detections) == 1 else False, - single=True, # @TODO solve issues with reading images in multiprocessing + detector_results=detector_results, example_config_param=data.config.example_config_param, - terminal=True, - # critera=data.config.criteria, # @TODO another approach to intermediate filter models + algorithms_used=algorithms_used, ) - classifier.run() end_time = time.time() seconds_elapsed = float(end_time - start_time) - algorithms_used[classifier.get_key()] = make_algorithm_response(classifier) - - # Return all detections, including those that were not classified as moths - detections_to_return += classifier.results logger.info( f"Processed {len(source_images)} images in {seconds_elapsed:.2f} seconds" @@ -336,9 +576,9 @@ async def readyz(): @TODO may need to simplify this to just return True/False. Pipeline algorithms will likely be loaded into memory on-demand when the pipeline is selected. """ - if _classifier_choices: + if _pipeline_choices: return fastapi.responses.JSONResponse( - status_code=200, content={"status": list(_classifier_choices.keys())} + status_code=200, content={"status": list(_pipeline_choices.keys())} ) else: return fastapi.responses.JSONResponse(status_code=503, content={"status": []}) @@ -357,10 +597,16 @@ async def readyz(): 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() - ] + pipeline_configs = [] + for slug, pipeline in PIPELINE_CHOICES.items(): + try: + pipeline_configs.append(make_pipeline_config_response(pipeline, slug=slug)) + except Exception as e: + # One pipeline that cannot be built must not take /info down with it, + # because that would make every other pipeline unavailable too. The + # pipeline is left out of the response and the reason is logged, so a + # stage whose weight cannot be fetched costs only its own pipeline. + logger.warning(f"Skipping pipeline '{slug}' in /info: {e}") _info = ProcessingServiceInfoResponse( name="Antenna Inference API", diff --git a/trapdata/api/datasets.py b/trapdata/api/datasets.py index 57cf9ba..07dde2e 100644 --- a/trapdata/api/datasets.py +++ b/trapdata/api/datasets.py @@ -75,8 +75,10 @@ def __getitem__(self, idx): if not image_data: return None bbox = detection.bbox - coords = bbox.x1, bbox.y1, bbox.x2, bbox.y2 - assert all(coord is not None for coord in coords) + # Clamp to the image's pixel bounds so this PIL-crop runner and the + # worker's tensor-slicing runner crop the SAME in-bounds region (see + # BoundingBox.clamp_to_bounds); PIL otherwise zero-pads out-of-bounds. + coords = bbox.clamp_to_bounds(image_data.width, image_data.height) image_data = image_data.crop(coords) # type: ignore image_data = self.image_transforms(image_data) diff --git a/trapdata/api/models/classification.py b/trapdata/api/models/classification.py index e604f3c..d86ab0a 100644 --- a/trapdata/api/models/classification.py +++ b/trapdata/api/models/classification.py @@ -36,6 +36,13 @@ class APIMothClassifier( ): task_type = "classification" + # Multiplies the softmax scores before they are stored. A value below 1.0 + # tempers a chronically over-confident model so the UI does not read every + # prediction as near-certain. This is a display stopgap only: it scales all + # scores by the same positive constant, so it is argmax-invariant and never + # changes which label is predicted or any downstream label gate. + score_scale: float = 1.0 + def __init__( self, source_images: typing.Iterable[SourceImage], @@ -84,10 +91,12 @@ def post_process_batch(self, logits: torch.Tensor): labels = [self.category_map[i] for i in class_indices] logit = logits[i].tolist() + # score_scale tempers over-confident models for display; it is a + # positive constant so the stored logits and argmax are unaffected. result = ClassifierResult( labels=labels, logit=logit, - scores=pred.tolist(), + scores=(pred * self.score_scale).tolist(), ) batch_results.append(result) @@ -230,4 +239,10 @@ class MothClassifierGlobal(APIMothClassifier, GlobalMothSpeciesClassifier): class InsectOrderClassifier(APIMothClassifier, InsectOrderClassifier2025): - pass + # Scales every reported order score by a constant 0.9. The model has no "not + # an insect" class, so it is as confident on non-animal crops (smudges, plant + # matter) as on real ones; the scale keeps its output away from certainty + # without flattening the range. This is a display guard, not a calibration: it + # lowers low scores as well as high ones. Argmax-invariant, so the stored + # logits and the Lepidoptera gate are unaffected. See RolnickLab/ami-ml#75. + score_scale = 0.9 diff --git a/trapdata/api/models/localization.py b/trapdata/api/models/localization.py index 9ec1acd..f5054d7 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 ( + AnyBugObjectDetector_YOLO26, + MothObjectDetector_FasterRCNN_2023, +) from ..datasets import LocalizationImageDataset from ..schemas import AlgorithmReference, BoundingBox, DetectionResponse, SourceImage @@ -56,3 +59,61 @@ def save_detection(image_id, coords): def run(self) -> list[DetectionResponse]: super().run() return self.results + + +class APIAnyBugDetector(APIInferenceBaseClass, AnyBugObjectDetector_YOLO26): + """API wrapper around the YOLO26 "any-bug" detector. + + Mirrors :class:`APIMothDetector`'s request/response plumbing (dataset, + per-image ``DetectionResponse`` assembly) while inheriting the YOLO26 + inference, de-letterboxing, and EXIF-neutralizing behavior from + :class:`AnyBugObjectDetector_YOLO26`. + """ + + 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): + detections: list[DetectionResponse] = [] + + def save_detection(image_id, coords): + bbox = BoundingBox(x1=coords[0], y1=coords[1], x2=coords[2], y2=coords[3]) + detection = DetectionResponse( + source_image_id=image_id, + bbox=bbox, + inference_time=seconds_per_item, + algorithm=AlgorithmReference(name=self.name, key=self.get_key()), + timestamp=datetime.datetime.now(), + crop_image_url=None, + ) + return detection + + for image_id, image_output in zip(item_ids, batch_output): + for coords in image_output: + detection = save_detection(image_id, coords) + detections.append(detection) + + 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 a8b682a..9190a83 100644 --- a/trapdata/api/schemas.py +++ b/trapdata/api/schemas.py @@ -28,6 +28,23 @@ def to_path(self): def to_tuple(self): return (self.x1, self.y1, self.x2, self.y2) + def clamp_to_bounds(self, width: int, height: int) -> tuple[int, int, int, int]: + """Clamp this box to an image's pixel bounds as integer ``(x1, y1, x2, y2)``. + + Both crop runners go through this so they see the SAME in-bounds region + for a box that touches or extends past the image edge: the ``/process`` + path's PIL ``Image.crop`` zero-pads out-of-bounds coordinates, while the + worker's tensor slicing wraps negative indices, so without a shared clamp + the two paths would return different crops for the same detection. A box + entirely outside the image collapses to a zero-area (degenerate) region, + which the caller treats as uncroppable. + """ + x1 = max(0, min(int(self.x1), width)) + x2 = max(0, min(int(self.x2), width)) + y1 = max(0, min(int(self.y1), height)) + y2 = max(0, min(int(self.y2), height)) + return x1, y1, x2, y2 + class SourceImage(pydantic.BaseModel): model_config = pydantic.ConfigDict(extra="ignore", arbitrary_types_allowed=True) diff --git a/trapdata/api/tests/test_api.py b/trapdata/api/tests/test_api.py index 84dba6c..05dfce9 100644 --- a/trapdata/api/tests/test_api.py +++ b/trapdata/api/tests/test_api.py @@ -6,6 +6,7 @@ from trapdata.api.api import ( CLASSIFIER_CHOICES, + PIPELINE_CHOICES, PipelineChoice, PipelineRequest, PipelineResponse, @@ -15,7 +16,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) @@ -61,40 +62,40 @@ def test_pipeline_request(self): self.fail(f"Pipeline request did not return a valid response: {e}") def test_pipeline_config_with_binary_classifier(self): - binary_classifier_pipeline_choice = "moth_binary" - BinaryClassifier = CLASSIFIER_CHOICES[binary_classifier_pipeline_choice] - binary_classifier_instance = BinaryClassifier(source_images=[], detections=[]) - BinaryClassifierResponse = make_algorithm_response(binary_classifier_instance) + """A pipeline's advertised config lists one algorithm per configured + stage, terminal classifier last. A species pipeline runs detector, binary + moth filter, then species classifier, so it advertises three; the + binary-only pipeline has no intermediate filter, so it advertises two. + """ + binary_pipeline_slug = "moth_binary" + binary_pipeline = PIPELINE_CHOICES[binary_pipeline_slug] + binary_classifier = binary_pipeline.terminal(source_images=[], detections=[]) + binary_classifier_key = make_algorithm_response(binary_classifier).key - species_classifier_pipeline_choice = "quebec_vermont_moths_2023" - SpeciesClassifier = CLASSIFIER_CHOICES[species_classifier_pipeline_choice] - species_classifier_instance = SpeciesClassifier(source_images=[], detections=[]) - SpeciesClassifierResponse = make_algorithm_response(species_classifier_instance) + species_pipeline_slug = "quebec_vermont_moths_2023" + species_pipeline = PIPELINE_CHOICES[species_pipeline_slug] + species_classifier = species_pipeline.terminal(source_images=[], detections=[]) + species_classifier_key = make_algorithm_response(species_classifier).key - # Test using a pipeline that finishes with a full species classifier + # A pipeline that finishes with a full species classifier. pipeline_config = make_pipeline_config_response( - SpeciesClassifier, - slug=species_classifier_pipeline_choice, + species_pipeline, + slug=species_pipeline_slug, ) self.assertEqual(len(pipeline_config.algorithms), 3) - self.assertEqual( - pipeline_config.algorithms[-1].key, SpeciesClassifierResponse.key - ) - self.assertEqual( - pipeline_config.algorithms[1].key, BinaryClassifierResponse.key - ) + self.assertEqual(pipeline_config.algorithms[-1].key, species_classifier_key) + self.assertEqual(pipeline_config.algorithms[1].key, binary_classifier_key) - # Test using a pipeline that finishes only with a binary classifier + # A pipeline that finishes only with a binary classifier. pipeline_config_binary_only = make_pipeline_config_response( - BinaryClassifier, slug=binary_classifier_pipeline_choice + binary_pipeline, slug=binary_pipeline_slug ) self.assertEqual(len(pipeline_config_binary_only.algorithms), 2) self.assertEqual( - pipeline_config_binary_only.algorithms[-1].key, BinaryClassifierResponse.key + pipeline_config_binary_only.algorithms[-1].key, binary_classifier_key ) - # self.assertTrue(pipeline_config_binary_only.algorithms[-1].terminal) def test_processing_with_only_binary_classifier(self): binary_classifier_pipeline_choice = "moth_binary" diff --git a/trapdata/api/tests/test_models.py b/trapdata/api/tests/test_models.py index 4b74f29..0850d9a 100644 --- a/trapdata/api/tests/test_models.py +++ b/trapdata/api/tests/test_models.py @@ -7,8 +7,11 @@ import unittest from unittest import TestCase +import numpy as np import PIL.Image import pytest +import torch +import torchvision.transforms from trapdata.api.models.classification import ( MothClassifierBinary, @@ -22,6 +25,10 @@ SourceImage, ) from trapdata.common.filemanagement import find_images +from trapdata.ml.models.localization import ( + AnyBugObjectDetector_YOLO26, + MothObjectDetector_FasterRCNN_2023, +) from trapdata.tests import TEST_IMAGES_BASE_PATH logging.basicConfig(level=logging.INFO) @@ -260,18 +267,19 @@ def test_filepath(self): img.close() def test_url(self): - # Don't trust placeholder image services - # Don't trust placeholder image services + # Don't trust placeholder image services. Wikimedia serves thumbnails + # only at the widths listed on https://w.wiki/GHai and returns HTTP 400 + # for any other width, so this width must stay on that list. url = ( "https://upload.wikimedia.org/wikipedia/en/thumb/8/80/" - "Wikipedia-logo-v2.svg/103px-Wikipedia-logo-v2.svg.png" + "Wikipedia-logo-v2.svg/120px-Wikipedia-logo-v2.svg.png" ) source_image = SourceImage(id="1", url=url) self.assertEqual(source_image.url, url) img = source_image.open() self.assertIsNotNone(img) assert img is not None - self.assertEqual(img.size, (103, 94)) + self.assertEqual(img.size, (120, 110)) img.close() def test_bad_base64(self): @@ -295,5 +303,277 @@ def test_base64_images(self): self._test_base64(image) +class _StubYOLO: + """Minimal stand-in for the Ultralytics ``YOLO`` model. + + Records the exact ``images`` argument handed to ``predict`` so a test can + assert on the array the detector produced, with no weights loaded and no + inference run. + """ + + def __init__(self) -> None: + self.received_images: list[np.ndarray] | None = None + + def predict(self, images, **kwargs): + self.received_images = images + return [] # post-processing is not exercised in these tests + + +# A known 2x2 RGB pattern with distinct per-channel values, so a wrong channel +# order or a transposed axis is caught by exact comparison. +_KNOWN_RGB = np.array( + [ + [[10, 20, 30], [40, 50, 60]], + [[70, 80, 90], [100, 110, 120]], + ], + dtype=np.uint8, +) # HWC RGB, shape (2, 2, 3) + + +class TestAnyBugDetectorInputFormat(TestCase): + """The YOLO26 detector must accept both dataloaders' image formats. + + The async worker's shared dataloader feeds CHW float ``ToTensor`` tensors, + while the FastAPI ``/process`` path feeds HWC uint8 arrays. Both must reach + Ultralytics as HWC uint8 BGR. These tests exercise only the format + conversion in ``predict_batch`` (up to, but not including, the real model + call, which is stubbed) — no weights, no GPU, no inference. + """ + + def _make_detector(self, stub: _StubYOLO) -> AnyBugObjectDetector_YOLO26: + # Bypass __init__ so no weights are downloaded and ultralytics is never + # imported; set only the attributes predict_batch reads. + detector = AnyBugObjectDetector_YOLO26.__new__(AnyBugObjectDetector_YOLO26) + detector.model = stub # type: ignore[assignment] + detector.device = "cpu" + detector.bbox_score_threshold = 0.25 + return detector + + def test_async_chw_float_tensor_converted_to_hwc_uint8_bgr(self): + """A CHW float ToTensor batch (async path) reaches Ultralytics as HWC + uint8 BGR, round-tripping the known pixel pattern.""" + pil = PIL.Image.fromarray(_KNOWN_RGB, mode="RGB") + chw_float = torchvision.transforms.ToTensor()(pil) # (3, 2, 2) float32 in [0,1] + self.assertEqual(tuple(chw_float.shape), (3, 2, 2)) + self.assertTrue(torch.is_floating_point(chw_float)) + # rest_collate_fn stacks same-size images into an NCHW tensor. + batch = torch.stack([chw_float]) + + stub = _StubYOLO() + self._make_detector(stub).predict_batch(batch) + + assert stub.received_images is not None + self.assertEqual(len(stub.received_images), 1) + arr = stub.received_images[0] + self.assertEqual(arr.shape, (2, 2, 3), "must be HWC, not CHW") + self.assertEqual(arr.dtype, np.uint8, "must be uint8, not float") + # Channels reversed RGB -> BGR, pixel values recovered from [0,1] float. + np.testing.assert_array_equal(arr, _KNOWN_RGB[..., ::-1]) + + def test_async_chw_float_list_batch_converted(self): + """The mixed-size async fallback (a list of CHW float tensors) is also + converted, so predict_batch does not depend on the stacked fast path.""" + pil = PIL.Image.fromarray(_KNOWN_RGB, mode="RGB") + chw_float = torchvision.transforms.ToTensor()(pil) + batch = [chw_float] # list, as rest_collate_fn yields for mixed sizes + + stub = _StubYOLO() + self._make_detector(stub).predict_batch(batch) + + assert stub.received_images is not None + arr = stub.received_images[0] + self.assertEqual(arr.shape, (2, 2, 3)) + self.assertEqual(arr.dtype, np.uint8) + np.testing.assert_array_equal(arr, _KNOWN_RGB[..., ::-1]) + + def test_process_hwc_uint8_tensor_unchanged(self): + """The /process path (HWC uint8 collated into an NHWC tensor) is passed + through unchanged apart from the RGB->BGR flip — the regression guard + for the working FastAPI path.""" + batch = torch.from_numpy(np.stack([_KNOWN_RGB])) # NHWC uint8 + + stub = _StubYOLO() + self._make_detector(stub).predict_batch(batch) + + assert stub.received_images is not None + arr = stub.received_images[0] + self.assertEqual(arr.shape, (2, 2, 3)) + self.assertEqual(arr.dtype, np.uint8) + np.testing.assert_array_equal(arr, _KNOWN_RGB[..., ::-1]) + + def test_process_hwc_uint8_ndarray_unchanged(self): + """The /process path when the batch arrives as a raw 4D ndarray.""" + batch = np.stack([_KNOWN_RGB]) # (1, 2, 2, 3) uint8 + + stub = _StubYOLO() + self._make_detector(stub).predict_batch(batch) + + assert stub.received_images is not None + arr = stub.received_images[0] + self.assertEqual(arr.shape, (2, 2, 3)) + self.assertEqual(arr.dtype, np.uint8) + np.testing.assert_array_equal(arr, _KNOWN_RGB[..., ::-1]) + + def test_helper_transposes_and_rescales_chw_float(self): + """_as_hwc_uint8_rgb converts a CHW float array to HWC uint8 directly.""" + chw_float = torchvision.transforms.ToTensor()( + PIL.Image.fromarray(_KNOWN_RGB, mode="RGB") + ) + out = AnyBugObjectDetector_YOLO26._as_hwc_uint8_rgb(chw_float) + self.assertEqual(out.shape, (2, 2, 3)) + self.assertEqual(out.dtype, np.uint8) + # Still RGB here — the flip to BGR happens in predict_batch, not the helper. + np.testing.assert_array_equal(out, _KNOWN_RGB) + + # --- Asserted-contract guards: the conversion must fail loudly rather than + # silently corrupt when an upstream transform changes the input format. --- + + def test_imagenet_normalized_float_raises(self): + """A mean/std standardized float tensor (values outside [0, 1]) is + rejected, not silently rescaled — converting it as if it were ToTensor + output would corrupt every pixel handed to the model.""" + chw = torchvision.transforms.ToTensor()( + PIL.Image.fromarray(_KNOWN_RGB, mode="RGB") + ) + standardized = (chw - 0.5) / 0.25 # now well outside [0, 1] + self.assertTrue(torch.is_floating_point(standardized)) + with self.assertRaises(ValueError): + AnyBugObjectDetector_YOLO26._as_hwc_uint8_rgb(standardized) + + def test_already_hwc_float_raises(self): + """An already channels-last float array (HWC, in range) is rejected + rather than transposed into garbage, since its channel axis is not where + the ToTensor contract puts it.""" + hwc_float = _KNOWN_RGB.astype(np.float32) / 255.0 # (2, 2, 3), in [0, 1] + with self.assertRaises(ValueError): + AnyBugObjectDetector_YOLO26._as_hwc_uint8_rgb(hwc_float) + + def test_integer_chw_raises(self): + """An integer channels-first array is rejected rather than passed through + in the wrong layout.""" + chw_uint8 = np.transpose(_KNOWN_RGB, (2, 0, 1)) # (3, 2, 2) integer CHW + with self.assertRaises(ValueError): + AnyBugObjectDetector_YOLO26._as_hwc_uint8_rgb(chw_uint8) + + def test_unexpected_rank_raises(self): + """A 2D array (neither a single image nor a batch) is rejected.""" + with self.assertRaises(ValueError): + AnyBugObjectDetector_YOLO26._as_hwc_uint8_rgb( + np.zeros((5, 5), dtype=np.uint8) + ) + + def test_4d_nchw_float_batch_handled(self): + """A 4D NCHW float batch is converted per-image to NHWC uint8, preserving + the batch axis.""" + chw = torchvision.transforms.ToTensor()( + PIL.Image.fromarray(_KNOWN_RGB, mode="RGB") + ) + nchw = torch.stack([chw, chw]) # (2, 3, 2, 2) + out = AnyBugObjectDetector_YOLO26._as_hwc_uint8_rgb(nchw) + self.assertEqual(out.shape, (2, 2, 2, 3)) # NHWC + self.assertEqual(out.dtype, np.uint8) + np.testing.assert_array_equal(out[0], _KNOWN_RGB) + np.testing.assert_array_equal(out[1], _KNOWN_RGB) + + def test_float_rescale_rounds_not_truncates(self): + """The [0, 1]->0-255 rescale rounds (np.rint) rather than truncating. + The values are deliberately NOT multiples of 1/255, and their *255 lands + clearly above the .5 boundary, so truncation would give a different (one + lower) uint8 on every channel.""" + # *255 -> 200.7, 10.6, 128.8 ; round -> 201, 11, 129 ; truncate -> 200, 10, 128 + chw = np.array( + [[[200.7 / 255]], [[10.6 / 255]], [[128.8 / 255]]], dtype=np.float32 + ) # (3, 1, 1) CHW + out = AnyBugObjectDetector_YOLO26._as_hwc_uint8_rgb(chw) + self.assertEqual(out.shape, (1, 1, 3)) + np.testing.assert_array_equal(out, np.array([[[201, 11, 129]]], dtype=np.uint8)) + + +class TestDetectorTransformContracts(TestCase): + """Pin each detector's per-image transform contract so the fix cannot drift + the FasterRCNN path or the YOLO /process path.""" + + def test_fasterrcnn_transform_is_chw_float_tensor(self): + """FasterRCNN still receives CHW float ToTensor input, unchanged.""" + detector = MothObjectDetector_FasterRCNN_2023.__new__( + MothObjectDetector_FasterRCNN_2023 + ) + transformed = detector.get_transforms()( + PIL.Image.fromarray(_KNOWN_RGB, mode="RGB") + ) + self.assertIsInstance(transformed, torch.Tensor) + self.assertTrue(torch.is_floating_point(transformed)) + self.assertEqual(tuple(transformed.shape), (3, 2, 2)) # CHW + self.assertLessEqual(float(transformed.max()), 1.0) + + def test_yolo_transform_is_hwc_uint8_array(self): + """The YOLO26 /process transform still yields an HWC uint8 RGB array.""" + detector = AnyBugObjectDetector_YOLO26.__new__(AnyBugObjectDetector_YOLO26) + transformed = detector.get_transforms()( + PIL.Image.fromarray(_KNOWN_RGB, mode="RGB") + ) + self.assertIsInstance(transformed, np.ndarray) + self.assertEqual(transformed.dtype, np.uint8) + self.assertEqual(transformed.shape, (2, 2, 3)) # HWC + np.testing.assert_array_equal(transformed, _KNOWN_RGB) + + +class TestBoundingBoxClampAndCropEquivalence(TestCase): + """The /process (PIL ``Image.crop``) and worker (tensor slice) runners must + crop the SAME in-bounds region for the same detection. Both clamp through + ``BoundingBox.clamp_to_bounds``, so a box that touches or exceeds the image + edge yields identical crops on both paths — PIL zero-pads out-of-bounds and + tensor slicing wraps negative indices otherwise. + """ + + W, H = 20, 12 + + def _fixture(self): + # Deterministic per-pixel values so a wrong crop region is caught exactly. + arr = np.arange(self.H * self.W * 3, dtype=np.uint8).reshape(self.H, self.W, 3) + pil = PIL.Image.fromarray(arr, mode="RGB") + chw = torch.from_numpy(arr).permute(2, 0, 1) # worker's CHW tensor layout + return pil, chw + + def test_clamp_to_bounds_cases(self): + """The clamp maps in-bounds, edge-touching, past-edge, and entirely-off + boxes to the expected integer region; an off-image box collapses to a + degenerate (zero-area) region.""" + cases = [ + (BoundingBox(x1=2, y1=2, x2=8, y2=6), (2, 2, 8, 6)), # in-bounds + (BoundingBox(x1=10, y1=0, x2=20, y2=12), (10, 0, 20, 12)), # edges + (BoundingBox(x1=15, y1=8, x2=40, y2=30), (15, 8, 20, 12)), # past BR + (BoundingBox(x1=-5, y1=-4, x2=6, y2=6), (0, 0, 6, 6)), # past TL + ( + BoundingBox(x1=25, y1=2, x2=30, y2=6), + (20, 2, 20, 6), + ), # off -> degenerate + ] + for box, expected in cases: + self.assertEqual(box.clamp_to_bounds(self.W, self.H), expected) + + def test_pil_and_tensor_runners_crop_same_region(self): + """For each box, the PIL crop of the clamped coords and the tensor slice + of the clamped coords are pixel-identical, including the past-top-left + case that would otherwise diverge (PIL zero-pads vs tensor negative-index + wrap) and the degenerate case (both yield an empty crop).""" + pil, chw = self._fixture() + boxes = [ + BoundingBox(x1=2, y1=2, x2=8, y2=6), # fully in-bounds + BoundingBox(x1=10, y1=0, x2=20, y2=12), # touching the edges + BoundingBox(x1=15, y1=8, x2=40, y2=30), # past the bottom-right edge + BoundingBox(x1=-5, y1=-4, x2=6, y2=6), # past the top-left edge + BoundingBox(x1=5, y1=5, x2=5, y2=10), # degenerate (zero width) + ] + for box in boxes: + x1, y1, x2, y2 = box.clamp_to_bounds(self.W, self.H) + pil_crop = np.asarray(pil.crop((x1, y1, x2, y2))) + tensor_crop = chw[:, y1:y2, x1:x2].permute(1, 2, 0).numpy() + self.assertEqual(pil_crop.shape, tensor_crop.shape, f"shape for {box}") + np.testing.assert_array_equal( + pil_crop, tensor_crop, err_msg=f"pixels for {box}" + ) + + if __name__ == "__main__": unittest.main() diff --git a/trapdata/api/tests/test_pipeline_registry.py b/trapdata/api/tests/test_pipeline_registry.py new file mode 100644 index 0000000..268c4c9 --- /dev/null +++ b/trapdata/api/tests/test_pipeline_registry.py @@ -0,0 +1,887 @@ +"""Unit tests for the staged pipeline registry and its execution. + +These tests are deliberately weight-free: they stub the model stages so nothing +loads real detector or classifier weights, keeping them fast and runnable on a +machine without a GPU. They pin four properties that are invisible in a diff and +have regressed before: + +* the pipelines the service advertises, the worker subscribes to, and job + dispatch accepts are the same set (no advertise-but-dead / execute-but-hidden + drift); +* the staged ``/process`` execution assembles results by detection identity, so + a gate-dropped or un-croppable detection keeps its own label instead of + shifting its neighbours'; +* a passing detection carries both its (non-terminal) gate label and its + terminal label, so the platform never shows the gate label as the best result; +* the over-confidence ``score_scale`` tempers reported scores only, leaving the + stored logits honest. +""" + +import datetime +from unittest import mock + +import pytest +import torch + +from trapdata.api import api +from trapdata.api.api import ( + PIPELINE_CHOICES, + IntermediateStage, + PipelineDefinition, + run_classification_stages, +) +from trapdata.api.models.classification import ( + APIMothClassifier, + InsectOrderClassifier, + MothClassifierGlobal, +) +from trapdata.api.models.localization import APIAnyBugDetector, APIMothDetector +from trapdata.api.schemas import ( + AlgorithmReference, + BoundingBox, + ClassificationResponse, + DetectionResponse, + PipelineConfigResponse, +) +from trapdata.cli.worker import default_pipeline_slugs + +# --------------------------------------------------------------------------- +# Weight-free stub stages +# --------------------------------------------------------------------------- + + +def _make_detection(detection_id: str) -> DetectionResponse: + return DetectionResponse( + source_image_id=detection_id, + bbox=BoundingBox(x1=0, y1=0, x2=1, y2=1), + algorithm=AlgorithmReference(name="stub-detector", key="stub_detector"), + timestamp=datetime.datetime.now(), + ) + + +class _StubClassifierStage: + """Stand-in for an ``APIMothClassifier`` stage that annotates the detections + it is handed in place, exactly as the real ``save_results`` does, without + loading any weights. + + ``task_type`` is set so the registry's shape validation accepts it as a + classification stage. Subclasses configure ``algorithm_key`` and the + per-detection ``labels`` map, and may list ``drop_ids`` to simulate crops the + model could not read (those detections stay in ``results`` un-annotated). + """ + + task_type = "classification" + name = "stub-classifier" + description = None + weights_path = None + category_map: dict = {} # falsy -> make_algorithm_response emits no category map + + algorithm_key = "stub_classifier" + labels: dict[str, str] = {} + drop_ids: frozenset[str] = frozenset() + + def __init__(self, source_images=None, detections=(), terminal=True, **kwargs): + self.detections = list(detections) + self.terminal = terminal + self.results: list[DetectionResponse] = [] + + def get_key(self) -> str: + return self.algorithm_key + + def run(self) -> list[DetectionResponse]: + for detection in self.detections: + if detection.source_image_id in self.drop_ids: + # Un-croppable detection: left un-annotated but still returned, + # mirroring the real classifier whose results == its detections. + continue + label = self.labels[detection.source_image_id] + detection.classifications = [ + c + for c in detection.classifications + if c.algorithm.key != self.get_key() + ] + detection.classifications.append( + ClassificationResponse( + classification=label, + scores=[1.0], + logits=[0.0], + algorithm=AlgorithmReference(name=self.name, key=self.get_key()), + terminal=self.terminal, + timestamp=datetime.datetime.now(), + ) + ) + self.results = self.detections + return self.results + + +def _classification_for(detection: DetectionResponse, key: str): + for classification in detection.classifications: + if classification.algorithm.key == key: + return classification + return None + + +# --------------------------------------------------------------------------- +# Item 3: advertised == subscribed == dispatchable +# --------------------------------------------------------------------------- + + +def test_advertised_subscribed_and_dispatchable_slugs_match(): + """Every pipeline in the registry is advertised by /info, subscribed to by a + default worker, and dispatchable by the worker's job lookup — with no slug + present in one view but missing from another. + + ``make_pipeline_config_response`` is stubbed so ``initialize_service_info`` + builds a config for every pipeline without loading weights (otherwise a + pipeline whose weight is absent, such as the anybug detector, would silently + drop out of /info and mask exactly the drift this test guards against). + """ + + def _fake_config(pipeline, slug): + return PipelineConfigResponse(name=slug, slug=slug, version=1, algorithms=[]) + + with mock.patch.object(api, "make_pipeline_config_response", _fake_config): + advertised = {p.slug for p in api.initialize_service_info().pipelines} + + subscribed = set(default_pipeline_slugs()) + dispatchable = set(PIPELINE_CHOICES.keys()) + + assert advertised == subscribed == dispatchable + # The regression that motivated this test: the new anybug pipeline must be in + # all three sets, not advertised-but-undispatchable. + assert "anybug_global_moths_2024" in advertised + + +# --------------------------------------------------------------------------- +# Advertised algorithm list: exactly one detector per pipeline +# --------------------------------------------------------------------------- +# +# Antenna's save_results rejects any pipeline whose /info advertises more than one +# localization ("detection") algorithm, so detections never save. A pre-refactor +# builder hardcoded the FasterRCNN moth detector for every pipeline; layering the +# anybug pipeline (which has its own YOLO26 detector) on top advertised TWO +# detectors. The typed PipelineDefinition now sources the detector from a single +# validated field, and make_pipeline_config_response emits one algorithm per +# stage. These tests pin both halves so the double-detector regression cannot +# return, and are weight-free (they read class task_types / stub the stages). +# +# Only the builder test below exercises make_pipeline_config_response itself, so +# it is the one that fails if a default detector is reintroduced there; the other +# two pin what the registry declares. Each docstring says which half it covers. + + +def _advertised_stage_classes(pipeline: PipelineDefinition) -> list[type]: + """The model classes make_pipeline_config_response emits one algorithm for, in + the order it emits them: the detector, each intermediate gate, then the + terminal classifier. + """ + return [ + pipeline.detector, + *(stage.classifier for stage in pipeline.intermediates), + pipeline.terminal, + ] + + +@pytest.mark.parametrize("slug", list(PIPELINE_CHOICES)) +def test_every_pipeline_advertises_exactly_one_localization_algorithm(slug): + """Each registered pipeline declares exactly one localization (detector) + stage — the count Antenna's save_results allows. + + PipelineDefinition already enforces this by construction, since it validates + that ``detector`` holds a localization model and every other slot holds a + classification model. This test therefore guards that validation rather than + the registry entries: it fails if a later change relaxes the roles or adds a + second detector slot. The builder half of the invariant, that nothing injects + an extra detector while assembling the response, is covered separately by + test_make_pipeline_config_response_emits_one_algorithm_per_stage. + """ + stages = _advertised_stage_classes(PIPELINE_CHOICES[slug]) + localization = [c for c in stages if c.task_type == "localization"] + assert len(localization) == 1, ( + f"{slug} advertises detectors {[c.__name__ for c in localization]}; " + "exactly one localization algorithm is allowed" + ) + + +def test_anybug_advertises_its_yolo26_detector_not_fasterrcnn(): + """The anybug pipeline advertises its own YOLO26 detector as the sole + localization algorithm, not the FasterRCNN moth detector a default-detector + builder would inject, and keeps its two classification stages intact. + """ + stages = _advertised_stage_classes(PIPELINE_CHOICES["anybug_global_moths_2024"]) + localization = [c for c in stages if c.task_type == "localization"] + assert localization == [APIAnyBugDetector] + assert localization[0].get_key() == "anybug-yolo26x-detector-2024" + # The spurious FasterRCNN detector must not appear anywhere in the stage list. + assert APIMothDetector not in stages + classification = [c for c in stages if c.task_type == "classification"] + assert len(classification) == 2 + + +# Stand-in stage models, so the builder can be exercised without downloading any +# weights. They carry only the attributes make_algorithm_config_response reads. + + +class _ConfigBuilderDetector: + task_type = "localization" + name = "Stub Detector" + description = "stub detector" + weights_path = "http://example.invalid/detector.pt" + labels_path = None + default_taxon_rank = "SPECIES" + category_map = {0: "object"} + + def __init__(self, source_images=(), **kwargs): + pass + + def get_key(self) -> str: + return "stub-detector" + + +class _ConfigBuilderGate: + task_type = "classification" + name = "Stub Order Gate" + description = "stub gate" + weights_path = "http://example.invalid/gate.pt" + labels_path = None + default_taxon_rank = "ORDER" + category_map = {0: "Lepidoptera", 1: "Coleoptera"} + + def __init__(self, source_images=(), detections=(), terminal=True, **kwargs): + pass + + def get_key(self) -> str: + return "stub-order-gate" + + +class _ConfigBuilderTerminal: + task_type = "classification" + name = "Stub Species Classifier" + description = "stub terminal" + weights_path = "http://example.invalid/species.pt" + labels_path = None + default_taxon_rank = "SPECIES" + category_map = {0: "Species A", 1: "Species B"} + + def __init__(self, source_images=(), detections=(), terminal=True, **kwargs): + pass + + def get_key(self) -> str: + return "stub-species" + + +def _stub_pipeline(**kwargs) -> PipelineDefinition: + """A three-stage pipeline of stub models: detector, one gate, terminal.""" + return PipelineDefinition( + detector=_ConfigBuilderDetector, + terminal=_ConfigBuilderTerminal, + intermediates=( + IntermediateStage( + classifier=_ConfigBuilderGate, pass_labels=("Lepidoptera",) + ), + ), + **kwargs, + ) + + +def test_make_pipeline_config_response_emits_one_algorithm_per_stage(): + """The /info builder emits exactly one algorithm per configured stage, in + stage order, and injects no extra detector of its own. This guards the builder + itself against re-introducing a hardcoded/default detector union (the shape of + the deployed regression), independently of the registry entries. + """ + config = api.make_pipeline_config_response(_stub_pipeline(), slug="stub_pipeline") + + task_types = [algorithm.task_type for algorithm in config.algorithms] + assert task_types == ["localization", "classification", "classification"] + localization = [a for a in config.algorithms if a.task_type == "localization"] + assert [a.key for a in localization] == ["stub-detector"] + + +# --------------------------------------------------------------------------- +# Advertised pipeline name: distinct per pipeline +# --------------------------------------------------------------------------- +# +# The platform lists pipelines by the name /info advertises, so two pipelines +# sharing a name are indistinguishable to the operator picking one. The name +# falls back to the terminal classifier's, which stayed unique only while every +# pipeline had a terminal classifier to itself. + + +def test_every_pipeline_advertises_a_distinct_name(): + """No two registered pipelines advertise the same name. + + Both "global_moths_2024" and "anybug_global_moths_2024" end in + MothClassifierGlobal, so before the anybug pipeline was given a name of its + own they both advertised "Global Species Classifier - Aug 2024" and appeared + as two identical entries. Reads class attributes only, so no weights load. + """ + slug_by_name: dict[str, str] = {} + for slug, pipeline in PIPELINE_CHOICES.items(): + advertised = pipeline.name or pipeline.terminal.name + assert advertised not in slug_by_name, ( + f"{slug!r} and {slug_by_name[advertised]!r} both advertise the name " + f"{advertised!r}; set PipelineDefinition.name on one of them" + ) + slug_by_name[advertised] = slug + + +def test_pipeline_config_name_prefers_the_pipeline_over_its_terminal(): + """The builder advertises the pipeline's own name and description when it has + them, and falls back to the terminal classifier's when it does not, so adding + the fields left the pipelines that omit them unchanged. + """ + named = api.make_pipeline_config_response( + _stub_pipeline(name="Custom Name", description="Custom description"), + slug="stub_pipeline", + ) + assert named.name == "Custom Name" + assert named.description == "Custom description" + + unnamed = api.make_pipeline_config_response(_stub_pipeline(), slug="stub_pipeline") + assert unnamed.name == _ConfigBuilderTerminal.name + assert unnamed.description == _ConfigBuilderTerminal.description + + +# --------------------------------------------------------------------------- +# Items 4 & 5: identity-keyed assembly, gate vs terminal labelling +# --------------------------------------------------------------------------- + + +def test_gate_merge_keeps_labels_by_detection_identity(): + """A gate that drops detections in the MIDDLE of the batch, followed by a + terminal classifier that cannot crop one of the passing detections, must + leave every detection with its own correct label. + + This is the property an index-based (zip-by-position) assembly would break: + after a dropped item, later detections would inherit their neighbours' + labels. The assembly keys on detection identity instead, so this pins that. + """ + + class _Gate(_StubClassifierStage): + algorithm_key = "order_gate" + name = "Order Gate" + labels = { + "d0": "Lepidoptera", # passes + "d1": "Coleoptera", # fails in the middle + "d2": "Lepidoptera", # passes + "d3": "Diptera", # fails in the middle + "d4": "Lepidoptera", # passes + } + + class _Terminal(_StubClassifierStage): + algorithm_key = "species" + name = "Species Classifier" + labels = {"d0": "Species A", "d2": "Species C", "d4": "Species E"} + drop_ids = frozenset({"d2"}) # a passing detection whose crop fails + + pipeline = PipelineDefinition( + detector=APIMothDetector, # never instantiated here; results passed in + terminal=_Terminal, + intermediates=( + IntermediateStage(classifier=_Gate, pass_labels=("Lepidoptera",)), + ), + ) + + detections = [_make_detection(f"d{i}") for i in range(5)] + algorithms_used: dict = {} + + returned = run_classification_stages( + pipeline=pipeline, + source_images=[], + detector_results=detections, + example_config_param=None, + algorithms_used=algorithms_used, + ) + + # Every detection is returned exactly once, none lost or duplicated. + returned_ids = [d.source_image_id for d in returned] + assert sorted(returned_ids) == ["d0", "d1", "d2", "d3", "d4"] + assert len(returned_ids) == len(set(returned_ids)) + + by_id = {d.source_image_id: d for d in returned} + + # Non-passing detections keep their own gate label (non-terminal) and get no + # terminal (species) classification. + for did, order in (("d1", "Coleoptera"), ("d3", "Diptera")): + gate_c = _classification_for(by_id[did], "order_gate") + assert gate_c is not None and gate_c.classification == order + assert gate_c.terminal is False + assert _classification_for(by_id[did], "species") is None + + # Passing + classified detections carry BOTH the non-terminal gate label and + # their own terminal species label — the correct one for that identity. + for did, species in (("d0", "Species A"), ("d4", "Species E")): + gate_c = _classification_for(by_id[did], "order_gate") + assert gate_c is not None and gate_c.classification == "Lepidoptera" + assert gate_c.terminal is False + species_c = _classification_for(by_id[did], "species") + assert species_c is not None and species_c.classification == species + assert species_c.terminal is True + + # The passing detection whose crop the terminal could not read keeps its gate + # label and is returned, but is NOT mislabelled with a neighbour's species. + d2_gate = _classification_for(by_id["d2"], "order_gate") + assert d2_gate is not None and d2_gate.classification == "Lepidoptera" + assert _classification_for(by_id["d2"], "species") is None + + +def test_passing_detection_marks_only_terminal_stage_terminal(): + """For a detection that passes the gate, the gate classification is marked + non-terminal and the terminal classification is marked terminal, so the + platform selects the species label as the best result rather than the gate. + """ + + class _Gate(_StubClassifierStage): + algorithm_key = "order_gate" + labels = {"only": "Lepidoptera"} + + class _Terminal(_StubClassifierStage): + algorithm_key = "species" + labels = {"only": "Some species"} + + pipeline = PipelineDefinition( + detector=APIMothDetector, + terminal=_Terminal, + intermediates=( + IntermediateStage(classifier=_Gate, pass_labels=("Lepidoptera",)), + ), + ) + + [detection] = run_classification_stages( + pipeline=pipeline, + source_images=[], + detector_results=[_make_detection("only")], + example_config_param=None, + algorithms_used={}, + ) + + terminal_labels = [ + c.classification for c in detection.classifications if c.terminal + ] + non_terminal_labels = [ + c.classification for c in detection.classifications if not c.terminal + ] + assert terminal_labels == ["Some species"] + assert non_terminal_labels == ["Lepidoptera"] + + +# --------------------------------------------------------------------------- +# Item 6: score_scale tempers scores only, logits stay honest +# --------------------------------------------------------------------------- + + +def test_score_scale_scales_scores_not_logits(): + """``post_process_batch`` multiplies the reported softmax scores by + ``score_scale`` but stores the raw, unscaled logits, so the true confidence + stays recoverable even when a chronically over-confident model is tempered. + """ + logits = torch.tensor([[2.0, 0.0, -1.0]]) + expected_softmax = torch.nn.functional.softmax(logits, dim=1)[0].tolist() + + fake_self = mock.Mock() + fake_self.category_map = {0: "a", 1: "b", 2: "c"} + fake_self.score_scale = 0.5 + + [result] = APIMothClassifier.post_process_batch(fake_self, logits) + + # Logits are stored raw (not multiplied by score_scale). + assert result.logit == pytest.approx(logits[0].tolist()) + # Scores are the softmax tempered by score_scale. + assert result.scores == pytest.approx([s * 0.5 for s in expected_softmax]) + # Tempering keeps argmax invariant: the top class is unchanged. + assert max(range(3), key=lambda i: result.scores[i]) == 0 + + +def test_order_gate_keeps_product_score_scale(): + """The Lepidoptera order gate is the InsectOrderClassifier, whose reported + scores are capped below certainty by an explicit product choice. Guard the + constant so a refactor does not quietly reset it to 1.0. + """ + assert InsectOrderClassifier.score_scale == 0.9 + assert api.LEPIDOPTERA_ORDER_GATE.classifier is InsectOrderClassifier + # The gate does not change which pipeline is terminal. + assert PIPELINE_CHOICES["anybug_global_moths_2024"].terminal is MothClassifierGlobal + + +# --------------------------------------------------------------------------- +# Item 7: stage dataclasses validate role/shape on construction +# --------------------------------------------------------------------------- + + +def test_pipeline_definition_rejects_wrong_roles(): + """The registry validates each slot's role on construction rather than + trusting the caller: a detector cannot be used where a classifier belongs + (or vice versa), and intermediates must be IntermediateStage instances. + """ + with pytest.raises(TypeError): + PipelineDefinition(detector=MothClassifierGlobal, terminal=MothClassifierGlobal) + + with pytest.raises(TypeError): + PipelineDefinition(detector=APIMothDetector, terminal=APIMothDetector) + + with pytest.raises(TypeError): + PipelineDefinition( + detector=APIMothDetector, + terminal=MothClassifierGlobal, + intermediates=(APIMothDetector,), # not an IntermediateStage + ) + + with pytest.raises(TypeError): + # A detector cannot fill an intermediate gate slot. + IntermediateStage(classifier=APIMothDetector) + + with pytest.raises(TypeError): + IntermediateStage(classifier=MothClassifierGlobal, pass_labels=["not", "tuple"]) + + +# --------------------------------------------------------------------------- +# Item 1: the worker's job dispatch reads the same registry +# --------------------------------------------------------------------------- + + +def test_worker_dispatch_runs_anybug_without_notimplemented(): + """The worker is now stage-driven, so the anybug pipeline dispatches through + the same path as the legacy pipelines instead of hitting a capability guard. + + With an empty job the worker completes (no work) without raising + NotImplementedError and without constructing any stage model — the YOLO26 + detector and order-gate weights are only loaded when there is data to + process, which keeps this test weight-free. This pins that the old + "advertised but undispatchable" guard is gone. + """ + from trapdata.antenna import worker + + with mock.patch.object(worker, "get_rest_dataloader", return_value=iter([])): + with mock.patch("torch.cuda.is_available", return_value=False): + did_work = worker._process_job( + "anybug_global_moths_2024", job_id=1, settings=mock.Mock() + ) + assert did_work is False + + +def test_worker_dispatch_accepts_legacy_pipeline_unchanged(): + """A legacy moth pipeline still dispatches through the migrated lookup with no + behavior change: given an empty job the worker completes without loading any + model weights. + """ + from trapdata.antenna import worker + + with mock.patch.object(worker, "get_rest_dataloader", return_value=iter([])): + with mock.patch("torch.cuda.is_available", return_value=False): + did_work = worker._process_job( + "quebec_vermont_moths_2023", job_id=1, settings=mock.Mock() + ) + assert did_work is False + + +# --------------------------------------------------------------------------- +# The worker's _process_batch is stage-driven (weight-free) +# --------------------------------------------------------------------------- +# +# These stubs stand in for the pipeline's detector and classifier stages so the +# worker's async inference core (_process_batch -> run_classification_stages) +# runs end-to-end without loading any weights. Each stage assigns labels by +# detection identity in update_detection_classification, so the crop tensors are +# irrelevant to the label a detection ends up with. + + +class _StubDetector: + """Weight-free stand-in for the pipeline's detector. Emits one full-frame + bounding box per image, mirroring APIMothDetector/APIAnyBugDetector's + predict/post-process/save contract over in-memory image tensors. + """ + + task_type = "localization" + name = "stub-detector" + + def __init__(self, source_images=(), **kwargs): + self.results: list[DetectionResponse] = [] + + def reset(self, source_images): + self.results = [] + + def predict_batch(self, images): + return list(images) + + def post_process_batch(self, batch_output): + # One box per image spanning the whole (16x16) stub tensor. + return [[[0, 0, 16, 16]] for _ in batch_output] + + def save_results(self, item_ids, batch_output, seconds_per_item, *args, **kwargs): + for image_id, image_output in zip(item_ids, batch_output): + for coords in image_output: + self.results.append( + DetectionResponse( + source_image_id=image_id, + bbox=BoundingBox( + x1=coords[0], y1=coords[1], x2=coords[2], y2=coords[3] + ), + algorithm=AlgorithmReference( + name=self.name, key="stub_detector" + ), + timestamp=datetime.datetime.now(), + ) + ) + + +class _StubTensorStage: + """Weight-free stand-in for an APIMothClassifier stage as the worker drives + it: reset -> get_transforms -> predict_batch -> post_process_batch -> + update_detection_classification. Labels come from ``labels`` keyed by + ``source_image_id`` so the assigned label does not depend on crop content. + """ + + task_type = "classification" + name = "stub-stage" + description = None + weights_path = None + category_map: dict = {} + + algorithm_key = "stub_stage" + labels: dict[str, str] = {} + + def __init__(self, source_images=(), detections=(), terminal=True, **kwargs): + self.detections = list(detections) + self.terminal = terminal + self.results: list[DetectionResponse] = [] + + def get_key(self) -> str: + return self.algorithm_key + + def reset(self, detections): + self.detections = list(detections) + self.results = [] + + def get_transforms(self): + # Resize every crop to a fixed shape so torch.stack succeeds regardless + # of bbox size; the label is applied by identity, not crop content. + return lambda crop: torch.zeros(3, 4, 4) + + def predict_batch(self, batched_crops): + return batched_crops + + def post_process_batch(self, batch_output): + # One placeholder prediction per crop; the real label is applied in + # update_detection_classification by detection identity. + return [None for _ in batch_output] + + def update_detection_classification( + self, seconds_per_item, image_id, detection_idx, predictions + ): + detection = self.detections[detection_idx] + detection.classifications = [ + c for c in detection.classifications if c.algorithm.key != self.get_key() + ] + detection.classifications.append( + ClassificationResponse( + classification=self.labels[detection.source_image_id], + scores=[1.0], + logits=[0.0], + algorithm=AlgorithmReference(name=self.name, key=self.get_key()), + terminal=self.terminal, + timestamp=datetime.datetime.now(), + ) + ) + return detection + + +def _make_stub_batch(image_ids: list[str]) -> dict: + return { + "images": [torch.zeros(3, 16, 16) for _ in image_ids], + "image_ids": list(image_ids), + "reply_subjects": [f"reply_{i}" for i in image_ids], + "image_urls": [f"http://example.com/{i}.jpg" for i in image_ids], + "failed_items": [], + } + + +def _classifications_by_algorithm(detection: DetectionResponse) -> dict: + return { + c.algorithm.key: (c.classification, c.terminal) + for c in detection.classifications + } + + +def test_process_batch_runs_anybug_stage_list_and_posts_labels(): + """The stage-driven worker runs the anybug stage list (detector -> order gate + -> terminal species) over in-memory tensors without NotImplementedError, and + posts each detection labelled by the stage that owns it: a gate-dropped + detection carries only its non-terminal order label, a passing detection + carries both the non-terminal order label and its terminal species label. + """ + from trapdata.antenna import worker + + class _OrderGate(_StubTensorStage): + algorithm_key = "order_gate" + name = "Order Gate" + labels = {"img0": "Lepidoptera", "img1": "Coleoptera", "img2": "Lepidoptera"} + + class _Species(_StubTensorStage): + algorithm_key = "species" + name = "Species Classifier" + labels = {"img0": "Species A", "img2": "Species B"} # only the passers + + pipeline_def = PipelineDefinition( + detector=_StubDetector, + terminal=_Species, + intermediates=( + IntermediateStage(classifier=_OrderGate, pass_labels=("Lepidoptera",)), + ), + ) + + image_ids = ["img0", "img1", "img2"] + n_items, n_detections, batch_results, _, _ = worker._process_batch( + _make_stub_batch(image_ids), + 0, + _StubDetector(), + worker._build_stage_models(pipeline_def), + pipeline_def, + "anybug_global_moths_2024", + ) + + assert n_items == 3 + # Every detection is posted exactly once: 1 gate-dropped + 2 terminal. + assert n_detections == 3 + assert len(batch_results) == 3 + + by_subject = {r.reply_subject: r.result for r in batch_results} + assert by_subject["reply_img0"].pipeline == "anybug_global_moths_2024" + + # img0 passes the order gate: non-terminal Lepidoptera + terminal species. + [d0] = by_subject["reply_img0"].detections + assert _classifications_by_algorithm(d0) == { + "order_gate": ("Lepidoptera", False), + "species": ("Species A", True), + } + + # img1 fails the order gate: only the non-terminal order label, no species. + [d1] = by_subject["reply_img1"].detections + assert _classifications_by_algorithm(d1) == {"order_gate": ("Coleoptera", False)} + + +def test_process_batch_legacy_binary_split_preserved(): + """A legacy binary pipeline still splits moth vs non-moth exactly: non-moth + detections are returned tagged with the non-terminal binary label and no + species, while moth detections carry the non-terminal binary label plus the + terminal species label. This is the behavior-preserving guarantee for the + detector+binary-gate+terminal pipelines now that they run through the shared + stage engine. + """ + from trapdata.antenna import worker + + class _Binary(_StubTensorStage): + algorithm_key = "binary" + name = "Moth / Non-Moth" + labels = {"img0": "moth", "img1": "nonmoth", "img2": "moth"} + + class _Species(_StubTensorStage): + algorithm_key = "species" + name = "Species Classifier" + labels = {"img0": "Species A", "img2": "Species B"} + + pipeline_def = PipelineDefinition( + detector=_StubDetector, + terminal=_Species, + intermediates=(IntermediateStage(classifier=_Binary, pass_labels=("moth",)),), + ) + + image_ids = ["img0", "img1", "img2"] + n_items, n_detections, batch_results, _, _ = worker._process_batch( + _make_stub_batch(image_ids), + 0, + _StubDetector(), + worker._build_stage_models(pipeline_def), + pipeline_def, + "quebec_vermont_moths_2023", + ) + + assert n_items == 3 + assert n_detections == 3 + by_subject = {r.reply_subject: r.result for r in batch_results} + + # Non-moth is returned tagged non-terminal binary, no species (the split is + # preserved: it never reaches the terminal classifier). + [d1] = by_subject["reply_img1"].detections + assert _classifications_by_algorithm(d1) == {"binary": ("nonmoth", False)} + + # Moth carries the non-terminal binary label plus the terminal species label. + [d0] = by_subject["reply_img0"].detections + assert _classifications_by_algorithm(d0) == { + "binary": ("moth", False), + "species": ("Species A", True), + } + + +def test_process_batch_tags_uncroppable_detection_instead_of_naked(): + """A detection whose bbox is degenerate (here zero-width) is returned TAGGED + with the non-terminal 'uncroppable' sentinel rather than naked, so the + pipeline's "every detection carries a classification" invariant holds. The + box never reaches the gate or terminal label, mirroring how a gate tags the + detections it drops. + """ + from trapdata.antenna import worker + + class _DegenerateDetector(_StubDetector): + def post_process_batch(self, batch_output): + # x1 == x2 -> zero-width box the crop loop cannot slice. + return [[[5, 5, 5, 12]] for _ in batch_output] + + class _OrderGate(_StubTensorStage): + algorithm_key = "order_gate" + name = "Order Gate" + labels = {"img0": "Lepidoptera"} # never applied: the crop is skipped + + class _Species(_StubTensorStage): + algorithm_key = "species" + name = "Species Classifier" + labels: dict[str, str] = {} + + pipeline_def = PipelineDefinition( + detector=_DegenerateDetector, + terminal=_Species, + intermediates=( + IntermediateStage(classifier=_OrderGate, pass_labels=("Lepidoptera",)), + ), + ) + + _, n_detections, batch_results, _, _ = worker._process_batch( + _make_stub_batch(["img0"]), + 0, + _DegenerateDetector(), + worker._build_stage_models(pipeline_def), + pipeline_def, + "anybug_global_moths_2024", + ) + + assert n_detections == 1 + [d0] = batch_results[0].result.detections + # Tagged with the non-terminal uncroppable sentinel, and NOTHING else: no + # gate label, no species label, and not a naked (classification-free) box. + assert _classifications_by_algorithm(d0) == { + worker.UNCROPPABLE_ALGORITHM.key: (worker.UNCROPPABLE_LABEL, False) + } + + +def test_classifier_choices_projection_is_collision_free(): + """CLASSIFIER_CHOICES is a DERIVED ``{slug: terminal}`` view of the + default-detector pipelines. Because it is keyed by the same unique slugs as + PIPELINE_CHOICES, deriving it can never merge two pipelines onto one slug or + drop one silently. Pin that so adding a pipeline keeps the projection 1:1. + """ + from trapdata.api.api import CLASSIFIER_CHOICES + + expected_slugs = { + slug + for slug, pipeline in PIPELINE_CHOICES.items() + if pipeline.detector is APIMothDetector + } + # Every default-detector pipeline appears exactly once, keyed by its slug... + assert set(CLASSIFIER_CHOICES) == expected_slugs + assert len(CLASSIFIER_CHOICES) == len(expected_slugs) + # ...its keys are a subset of the registry's unique slugs (no key can collide + # with another pipeline's)... + assert set(CLASSIFIER_CHOICES).issubset(PIPELINE_CHOICES) + # ...and each derived terminal matches its source pipeline (no cross-wiring). + for slug, terminal in CLASSIFIER_CHOICES.items(): + assert terminal is PIPELINE_CHOICES[slug].terminal diff --git a/trapdata/api/tests/utils.py b/trapdata/api/tests/utils.py index eda17bc..457850e 100644 --- a/trapdata/api/tests/utils.py +++ b/trapdata/api/tests/utils.py @@ -27,12 +27,17 @@ def get_test_image_urls( num: Number of images to return (default: 2) Returns: - List of image URLs from the file server + List of image URLs from the file server, in filename order """ images_dir = test_images_dir / subdir + # Sort before truncating to ``num``. ``glob`` yields directory order, which + # varies between machines, so an unsorted selection hands different tests + # different images on different checkouts. The vermont set spans a whole + # trapping night and its dusk frame holds no insects at all, so a caller + # asking for one image would sometimes get a frame with nothing to detect. source_image_urls = [ file_server.get_url(f.relative_to(test_images_dir)) - for f in images_dir.glob("*.jpg") + for f in sorted(images_dir.glob("*.jpg")) ][:num] return source_image_urls diff --git a/trapdata/cli/worker.py b/trapdata/cli/worker.py index f1b5782..9b4826a 100644 --- a/trapdata/cli/worker.py +++ b/trapdata/cli/worker.py @@ -4,11 +4,20 @@ 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") +def default_pipeline_slugs() -> list[str]: + """The pipelines a worker subscribes to when none are named on the command + line: every pipeline the service advertises. Read from PIPELINE_CHOICES, the + same registry /info and job dispatch use, so the worker cannot subscribe to a + different set than it advertises or can dispatch. + """ + return list(PIPELINE_CHOICES.keys()) + + @cli.callback(invoke_without_command=True) def run( ctx: typer.Context, @@ -31,16 +40,18 @@ def run( return if not pipelines: - pipelines = list(CLASSIFIER_CHOICES.keys()) + pipelines = default_pipeline_slugs() - # Validate that each pipeline is in CLASSIFIER_CHOICES + # Validate against the full pipeline registry, not the legacy classifier + # shim, so newer pipelines (e.g. the anybug detector) can be requested. + valid_slugs = list(PIPELINE_CHOICES.keys()) invalid_pipelines = [ - pipeline for pipeline in pipelines if pipeline not in CLASSIFIER_CHOICES.keys() + pipeline for pipeline in pipelines if pipeline not in valid_slugs ] if invalid_pipelines: raise typer.BadParameter( - f"Invalid pipeline(s): {', '.join(invalid_pipelines)}. Must be one of: {', '.join(CLASSIFIER_CHOICES.keys())}" + f"Invalid pipeline(s): {', '.join(invalid_pipelines)}. Must be one of: {', '.join(valid_slugs)}" ) from trapdata.antenna.worker import run_worker diff --git a/trapdata/ml/models/localization.py b/trapdata/ml/models/localization.py index 3f5b427..f05a5f4 100644 --- a/trapdata/ml/models/localization.py +++ b/trapdata/ml/models/localization.py @@ -1,5 +1,6 @@ import pathlib +import numpy as np import torch import torchvision import torchvision.models.detection.anchor_utils @@ -287,6 +288,192 @@ def post_process_single(self, output): return bboxes +class AnyBugObjectDetector_YOLO26(ObjectDetector): + """Ultralytics YOLO26 "any-bug" object detector. + + Emits bounding boxes as absolute raw-pixel ``xyxy`` in the ORIGINAL image + space, matching Antenna's raw-pixel contract. Two coordinate-space hazards + are handled explicitly: + + 1. Letterboxing. Ultralytics letterboxes each image for inference and maps + its predicted boxes back to the input array's pixel space, so the + ``xyxy`` values read from the result are already de-letterboxed to + original pixels. + 2. EXIF auto-transpose. Ultralytics applies ``ImageOps.exif_transpose`` to + PIL inputs, which would rotate portrait or otherwise EXIF-tagged captures + and misplace boxes relative to the raw-pixel contract. :meth:`predict_batch` + always hands the model raw NumPy arrays (never PIL images), which carry no + EXIF, so no auto-rotation is applied and boxes stay in raw-pixel space. + + Two dataloaders feed this detector with different single-image formats, and + :meth:`predict_batch` accepts both (see :meth:`_as_hwc_uint8_rgb`): the + FastAPI ``/process`` path uses this detector's own :meth:`get_transforms` + (HWC uint8 RGB), while the async worker's shared dataloader hardcodes + ``ToTensor`` (CHW float32 RGB in ``[0, 1]``) because its classifier stages + slice CHW crops from the same tensors. + + The (AGPL-3.0) ``ultralytics`` package is a required dependency, but it is + imported inside :meth:`get_model` rather than at module scope so the other + detectors here do not pay its import cost. + """ + + name = "AnyBug YOLO26x Detector 2024" + key = "anybug-yolo26x-detector-2024" + weights_path = ( + "https://object-arbutus.cloud.computecanada.ca/ami-models/moths/" + "localization/yolo26x-anybug-v1.pt" + ) + description = ( + "Ultralytics YOLO26x 'any-bug' object detector: the larger, server-side " + "flavor of the combined flat-bug + Fieldguide detector, chosen for batch " + "GPU accuracy over edge latency. Outputs raw-pixel xyxy boxes in the " + "original image space." + ) + # TODO(anybug): 0.25 is a starting default; tune against validation data. + bbox_score_threshold = 0.25 + + # Pin inference resolution. The checkpoint trains at 1024 and Ultralytics + # already restores that via _reset_ckpt_args, so this is a no-op today; it + # guards against a future release dropping that restore and silently falling + # back to the 640 predict default, which would cut recall on small insects. + imgsz = 1024 + + def get_transforms(self): + # Convert the PIL image to a raw HWC RGB uint8 NumPy array. NumPy arrays + # carry no EXIF metadata, which neutralizes Ultralytics' default + # ImageOps.exif_transpose so predicted boxes land in raw-pixel space. + return torchvision.transforms.Compose([np.asarray]) + + def get_model(self): + # Imported here rather than at module scope so that importing this module + # does not pull in ultralytics and its own heavy dependency chain for the + # detectors that never touch it. + from ultralytics import YOLO + + logger.debug(f"Loading YOLO26 weights: {self.weights}") + model = YOLO(self.weights) + model.to(self.device) + self.model = model + return self.model + + # A float image is accepted as [0, 1] within this tolerance; anything beyond + # it (e.g. a mean/std standardized tensor) is rejected rather than clipped. + _FLOAT_RANGE_EPS = 1e-3 + + @staticmethod + def _as_hwc_uint8_rgb(image: "np.ndarray | torch.Tensor") -> np.ndarray: + """Convert one image (or an NCHW/NHWC batch) to HWC uint8 RGB for Ultralytics. + + The two dataloaders feed this detector mutually exclusive formats, and the + conversion is an ASSERTED CONTRACT rather than a dtype guess: a future + change to a dataloader transform must fail loudly here instead of silently + handing the model corrupted pixels. + + * FastAPI ``/process`` applies this detector's own :meth:`get_transforms` + (``np.asarray``), yielding channels-last uint8 RGB (HWC or NHWC). + * The async worker's shared dataloader hardcodes ``torchvision`` + ``ToTensor`` — it must, so the classifier stages can slice CHW crops + from the same tensors — yielding channels-first float32 RGB scaled to + ``[0, 1]`` (CHW or NCHW). Reaching Ultralytics as channels-first float, + the RGB->BGR flip in :meth:`predict_batch` would mirror the width axis + instead of swapping channels, feeding the model garbage. + + The contract, by dtype: + + * Floating point must be channels-first (``shape[-3] in {1, 3}``) and + within ``[0, 1]``; it is transposed to channels-last and rescaled to + 0-255 with rounding (``np.rint``), not truncation. + * Integer must be channels-last (``shape[-1] in {1, 3}``) and is returned + as uint8 unchanged, so the ``/process`` path stays byte-for-byte + identical. + * Anything else — an out-of-range float (a standardized tensor), an + already channels-last float, an integer channels-first array, or an + unexpected rank — raises :class:`ValueError` naming the offending shape, + dtype, and range. + """ + if isinstance(image, torch.Tensor): + # One full-image copy per call: the device->host transfer here plus + # the rescale allocation below. Negligible for a single frame, but do + # not fan many large images through this blindly — the cost scales + # with the total pixels handed in per call. + image = image.detach().cpu().numpy() + image = np.asarray(image) + + if image.ndim not in (3, 4): + raise ValueError( + "Expected a 3D single image or 4D batch (CHW/HWC/NCHW/NHWC), got " + f"shape {image.shape} dtype {image.dtype}" + ) + + if np.issubdtype(image.dtype, np.floating): + # ToTensor contract: channels-first float in [0, 1]. The channel axis + # is the third-from-last on both CHW and NCHW, so shape[-3] validates + # either rank. + low, high = float(np.min(image)), float(np.max(image)) + eps = AnyBugObjectDetector_YOLO26._FLOAT_RANGE_EPS + if low < -eps or high > 1.0 + eps: + raise ValueError( + "Floating-point image must be normalized to [0, 1] (ToTensor " + f"output); got value range [{low:.4g}, {high:.4g}] for shape " + f"{image.shape}. A standardized (mean/std) tensor is rejected " + "rather than silently clipped." + ) + if image.shape[-3] not in (1, 3): + raise ValueError( + "Floating-point image must be channels-first (CHW or NCHW) " + f"with 1 or 3 channels; got shape {image.shape}. An already " + "channels-last float array is rejected rather than transposed " + "into garbage." + ) + axes = (0, 2, 3, 1) if image.ndim == 4 else (1, 2, 0) + image = np.transpose(image, axes) + return np.clip(np.rint(image * 255.0), 0, 255).astype(np.uint8) + + # Integer input: require channels-last so an integer channels-first array + # cannot slip through in the wrong layout. + if image.shape[-1] not in (1, 3): + raise ValueError( + "Integer image must be channels-last (HWC or NHWC) with 1 or 3 " + f"channels; got shape {image.shape}. An integer channels-first " + "array is rejected rather than passed through in the wrong layout." + ) + return image.astype(np.uint8, copy=False) + + def predict_batch(self, batch): + # Ultralytics performs its own letterbox + normalization internally and + # returns one Results object per image with boxes already mapped back to + # that image's pixel space. It expects NumPy inputs as HWC uint8 arrays + # in BGR channel order (OpenCV convention). + # + # Accept either format the two dataloaders produce and normalize each + # image to HWC uint8 RGB via the asserted _as_hwc_uint8_rgb contract, then + # flip RGB->BGR. A 4D batch is split into single images so Ultralytics + # receives the list of images it expects. + if isinstance(batch, (np.ndarray, torch.Tensor)): + raw_images = list(batch) if batch.ndim == 4 else [batch] + else: + raw_images = list(batch) + images = [self._as_hwc_uint8_rgb(img) for img in raw_images] + images = [np.ascontiguousarray(img[..., ::-1]) for img in images] + # TODO(anybug): a mixed-resolution batch cannot be default-collated into + # a single tensor, so such batches arrive as a list and are passed through + # one by one; wire a list collate_fn for variable-size inputs. + return self.model.predict( + images, + conf=self.bbox_score_threshold, + imgsz=self.imgsz, + device=self.device, + verbose=False, + ) + + def post_process_single(self, result): + # result.boxes.xyxy are absolute pixel coordinates in the original image + # space (already de-letterboxed by Ultralytics). + bboxes = result.boxes.xyxy.cpu().numpy().astype(int).tolist() + logger.debug(f"YOLO26 detector kept {len(bboxes)} boxes") + return bboxes + + class MothObjectDetector_FasterRCNN_MobileNet_2023(ObjectDetector): name = "FasterRCNN - MobileNet for AMI Moth Traps 2023" weights_path = "https://object-arbutus.cloud.computecanada.ca/ami-models/moths/localization/fasterrcnn_mobilenet_v3_large_fpn_uqfh7u9w.pt" diff --git a/uv.lock b/uv.lock index aaf9688..b411bc0 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.63.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/84/69/c97f2c18e0db87d2c7b15da1974dace76ae938f1cfa22e2727a648b7ed43/fonttools-4.63.0.tar.gz", hash = "sha256:caeb583deeb5168e694b65cda8b4ee62abedfa66cf88488734466f2366b9c4e0", size = 3597189, upload-time = "2026-05-14T12:04:30.958Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/c9/4141c90a90db20f807c7e10bfd689fe53eb8f7f4caff58ee4d4dfe46919f/fonttools-4.63.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e3297a6a4059b4acc3a1e9a8b04741f240a80044eef08ebd32e8b5bcdddce75b", size = 2884632, upload-time = "2026-05-14T12:02:38.56Z" }, + { url = "https://files.pythonhosted.org/packages/b8/46/ad12b5c10eae602d7ef814b02afa08aacbf89da917fed5b071282b7eadc2/fonttools-4.63.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b1cd75a03ad8cb5bc40c90bfde68c0c47de423aa19e5c0f362b43520645eea94", size = 2429441, upload-time = "2026-05-14T12:02:41.162Z" }, + { url = "https://files.pythonhosted.org/packages/90/8f/bdca24a84c81d56fffed052229cdcff368f6e05882e526f4558891481f65/fonttools-4.63.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0425b277a59cff3d80ca42162a8de360f318438a2ac83570842a678d826d579", size = 4946346, upload-time = "2026-05-14T12:02:43.41Z" }, + { url = "https://files.pythonhosted.org/packages/04/59/a639c0e136441ee91a65b56fdf89e5d075927e7a09c559d1b0f5276577db/fonttools-4.63.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d7e5c9973aa04c95650c96e5f5ad865fbf42d62079163ecfab1e01cbc2504c22", size = 4903184, upload-time = "2026-05-14T12:02:45.742Z" }, + { url = "https://files.pythonhosted.org/packages/e6/53/91b7e0cb45b536f3da1b29ba8cbab89f27e8b986809e0b1982303a3f4eca/fonttools-4.63.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cb014d58140a38135f16064c74c652ed57aa0b75cbf8bb59cac821f7edb5334e", size = 4922967, upload-time = "2026-05-14T12:02:48.386Z" }, + { url = "https://files.pythonhosted.org/packages/c7/b7/87439bf44e6b97c5538cd29d0b7e366a5b8ce2cc132a4134fb67fa3f2fa2/fonttools-4.63.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:032038247a96c1690f9f31e377c389383c902531b085aa4e4dabd6f57f870e69", size = 5042799, upload-time = "2026-05-14T12:02:50.424Z" }, + { url = "https://files.pythonhosted.org/packages/ad/7c/8b96c3263b89ef99cded544c0f0636686f85dbd3c211c4dceef0231fca23/fonttools-4.63.0-cp310-cp310-win32.whl", hash = "sha256:a8b33a82979e0a6a34ff435cc81317be1f95ec1ebb7a3a2d1c8a6a54f02ae44e", size = 1519704, upload-time = "2026-05-14T12:02:52.523Z" }, + { url = "https://files.pythonhosted.org/packages/e5/4d/2c2f0069970b6907de8fb5b05c5c0193cc22f717df151d1c7aef1c738f58/fonttools-4.63.0-cp310-cp310-win_amd64.whl", hash = "sha256:0c18358a155d75034911c5ee397a5b44cd19dd325dbb8b35fb60bf421d6a72ac", size = 1568666, upload-time = "2026-05-14T12:02:54.917Z" }, + { url = "https://files.pythonhosted.org/packages/75/2b/a7f1545bdf5da69c4bda0cea2a5781f0ad2a6623e0277267672db43c5fe6/fonttools-4.63.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:2b8ae05d9eacf6081414d759c0a352769ac28ce31280d6bb8e77b03f9e3c449f", size = 2881793, upload-time = "2026-05-14T12:02:56.645Z" }, + { url = "https://files.pythonhosted.org/packages/49/50/965308c703f085f225db2886813b27e015b8b3438c350b22dd65b52c2a2c/fonttools-4.63.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:79cdc9f567aec74a72918fd060283911406750cbc9fd28c1316023deb6ce31a9", size = 2428130, upload-time = "2026-05-14T12:02:58.891Z" }, + { url = "https://files.pythonhosted.org/packages/d8/38/6937fbd7f2dc3a6b48725851bc2c15ec949b9af14d9bbcb5fe83cdf9bdf9/fonttools-4.63.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c14b4fd138c4bafcca294765c547914e1aa431ae1ca94ab99d8db08c958bd3b", size = 5111952, upload-time = "2026-05-14T12:03:01.263Z" }, + { url = "https://files.pythonhosted.org/packages/0b/43/a81f20050a3115b57d62c8e781446949512eac36690dc384ccea65ff4cc1/fonttools-4.63.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d76ac49f929aecaf82d83250b8347e099d7aecba0f4726c1d9b6df3b8bb5fe18", size = 5082308, upload-time = "2026-05-14T12:03:03.211Z" }, + { url = "https://files.pythonhosted.org/packages/67/00/cdd9d4944ca6ae280d01e69cc37bde3bf663630b837a6fc6d2cd65d80e0e/fonttools-4.63.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:dcf076a4474fe0d7367e5bbf5b052c7284fa1feca729c04176ce513521afd8a0", size = 5087932, upload-time = "2026-05-14T12:03:05.147Z" }, + { url = "https://files.pythonhosted.org/packages/f5/f1/0aa0dbea778c75adbef223c42019fd47d22262b905974d62d829545d485f/fonttools-4.63.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7dd683fef0663e9f0f45cf541d788d24caa3ec9db50796b588e1757d8b3bc007", size = 5213271, upload-time = "2026-05-14T12:03:07.238Z" }, + { url = "https://files.pythonhosted.org/packages/a8/99/253e4056e1f0e67b9390125a154b73b5eb73ad521bece95c004858fdeec2/fonttools-4.63.0-cp311-cp311-win32.whl", hash = "sha256:afefc1ed0a59785a7fb06ea7e1678e849c193e1e387db783579bc7b3056fcfcb", size = 2304473, upload-time = "2026-05-14T12:03:09.271Z" }, + { url = "https://files.pythonhosted.org/packages/08/60/defa5e69641db890a63be281f41345f4c33b157824eaf0b9fad3e08b0dcb/fonttools-4.63.0-cp311-cp311-win_amd64.whl", hash = "sha256:063e08bd17bd5a90127a14123de0d6a952dbc847695fd98b63c043d58057f90c", size = 2356389, upload-time = "2026-05-14T12:03:11.53Z" }, + { url = "https://files.pythonhosted.org/packages/08/ef/b3c6b9b5be2f82416d73fe2ed2e96e2793cd80e7510bd6a17ca79cdd88ec/fonttools-4.63.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:37dd23e621e3b0aef1baa70a303b80aaf38449632cfc8fd2a55fb285bbccfc02", size = 2881131, upload-time = "2026-05-14T12:03:13.386Z" }, + { url = "https://files.pythonhosted.org/packages/44/a0/c815bea63117fa63e4e1c01f8a1110d2112fa003f838e6467094ec2432ce/fonttools-4.63.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a9faff9e0c1f76f9fd55899d2ce785832efebab37eb8ae13995853aef178bef0", size = 2426704, upload-time = "2026-05-14T12:03:15.801Z" }, + { url = "https://files.pythonhosted.org/packages/44/04/0b91d8e916e92ad1fac9e4624760baf0fd5ff2ead614c2f68fb21373f03f/fonttools-4.63.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef3048ef05dbb552b89817713d9cac912e00d0fde4a3105c00d29e52e10c89af", size = 5044298, upload-time = "2026-05-14T12:03:18.085Z" }, + { url = "https://files.pythonhosted.org/packages/77/c7/2342da9830e3e9d4870305ca5d2091d2a83284f2953079b7bdd3b5e029d8/fonttools-4.63.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:58dc6bb86a78d782f00f9190ca02c119cf5bbe2807536e361e18d42019f877d8", size = 4999800, upload-time = "2026-05-14T12:03:20.161Z" }, + { url = "https://files.pythonhosted.org/packages/e6/6d/67fe16c48d7ce050979b33f47e0d28a318f02da030602e944c34f7a16ef3/fonttools-4.63.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ee08ebfa58f6e1aeff5697ab9582105bb620008c1caafb681e4c557e7483027b", size = 4982666, upload-time = "2026-05-14T12:03:22.87Z" }, + { url = "https://files.pythonhosted.org/packages/f2/00/3bbab338c07c71fa56269953845e92c951a61457bbbb0f1022551ea266d9/fonttools-4.63.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:27fdc65af8da6f88b9c6121c47a464cbe359fcfff7ff6fc2d37a1f395d755b78", size = 5133598, upload-time = "2026-05-14T12:03:25.168Z" }, + { url = "https://files.pythonhosted.org/packages/62/f2/aa27c7f98db5b064883dadcc5283947e81e034de42e22a33675878d98b54/fonttools-4.63.0-cp312-cp312-win32.whl", hash = "sha256:af2fd1664d00a397d75f806985ddb36282091c2131a73a6485c23b4a34722263", size = 2292575, upload-time = "2026-05-14T12:03:27.496Z" }, + { url = "https://files.pythonhosted.org/packages/87/36/cccb9bc2a6ab63d1b2980374f0dca72ce95ae267c9b4cfe77455bb70d0d4/fonttools-4.63.0-cp312-cp312-win_amd64.whl", hash = "sha256:59ac449f8cca9b4ffa08d2e7bbadad87ce710d69d1eda5c3c1ce579baa987272", size = 2343211, upload-time = "2026-05-14T12:03:30.057Z" }, + { url = "https://files.pythonhosted.org/packages/2c/47/c99d5268f354002ce80f8d029cd9d7d872969da1de8b93d32de4dc56d6f4/fonttools-4.63.0-py3-none-any.whl", hash = "sha256:445af2eab030a16b9171ea8bdda7ebf7d96bda2df88ee182a464252f6e05e20d", size = 1164562, upload-time = "2026-05-14T12:04:29.092Z" }, +] + [[package]] name = "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,99 @@ 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.9" +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 = "contourpy", version = "1.3.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "cycler", marker = "python_full_version < '3.11'" }, + { name = "fonttools", marker = "python_full_version < '3.11'" }, + { name = "kiwisolver", marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "packaging", marker = "python_full_version < '3.11'" }, + { name = "pillow", marker = "python_full_version < '3.11'" }, + { name = "pyparsing", marker = "python_full_version < '3.11'" }, + { name = "python-dateutil", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/63/1b/4be5be87d43d327a0cf4de1a56e86f7f84c89312452406cf122efe2839e6/matplotlib-3.10.9.tar.gz", hash = "sha256:fd66508e8c6877d98e586654b608a0456db8d7e8a546eb1e2600efd957302358", size = 34811233, upload-time = "2026-04-24T00:14:13.539Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/6f/340b04986e67aac6f66c5145ce68bf72c64bed30f92c8913499a6e6b8f99/matplotlib-3.10.9-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77210dce9cb8153dffc967efaae990543392563d5a376d4dd8539bebcb0ed217", size = 8296625, upload-time = "2026-04-24T00:11:43.376Z" }, + { url = "https://files.pythonhosted.org/packages/bb/2f/127081eb83162053ebb9678ceac64220b93a663e0167432566e9c7c82aab/matplotlib-3.10.9-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1e7698ac9868428e84d2c967424803b2472ff7167d9d6590d4204ed775343c3b", size = 8188790, upload-time = "2026-04-24T00:11:46.556Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b7/d8bcec2626c35f96972bff656299fef4578113ea6193c8fdad324710410c/matplotlib-3.10.9-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1aa972116abb4c9d201bf245620b433726cb6856f3bef6a78f776a00f5c92d37", size = 8769389, upload-time = "2026-04-24T00:11:48.959Z" }, + { url = "https://files.pythonhosted.org/packages/12/49/b78e214a527ea732033b7f4d37f7afb504d74ba9d134bd47938230dfb8b1/matplotlib-3.10.9-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae2f11957b27ce53497dd4d7b235c4d4f1faf383dfb39d0c5beb833bff883294", size = 9589657, upload-time = "2026-04-24T00:11:51.915Z" }, + { url = "https://files.pythonhosted.org/packages/5f/15/5246f7b43beae19c74dfee651d58d6cc8112e06f77adb4e88cc04f2e3a23/matplotlib-3.10.9-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b049278ddce116aaa1c1377ebf58adea909132dfce0281cf7e3a1ea9fc2e2c65", size = 9651983, upload-time = "2026-04-24T00:11:54.766Z" }, + { url = "https://files.pythonhosted.org/packages/75/77/5acecfe672ba0fa1b8c0454f69ce155d1e6fc5852fa7206bf9afaf767121/matplotlib-3.10.9-cp310-cp310-win_amd64.whl", hash = "sha256:82834c3c292d24d3a8aae77cd2d20019de69d692a34a970e4fdb8d33e2ea3dda", size = 8199701, upload-time = "2026-04-24T00:11:58.389Z" }, + { url = "https://files.pythonhosted.org/packages/4c/8c/290f021104741fea63769c31494f5324c0cd249bf536a65a4350767b1f22/matplotlib-3.10.9-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:68cfdcede415f7c8f5577b03303dd94526cdb6d11036cecdc205e08733b2d2bb", size = 8306860, upload-time = "2026-04-24T00:12:01.207Z" }, + { url = "https://files.pythonhosted.org/packages/51/18/325cd32ece1120d1da51cc4e4294c6580190699490183fc2fe8cb6d61ec5/matplotlib-3.10.9-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dfca0129678bd56379db26c52b5d77ed7de314c047492fbdc763aa7501710cfb", size = 8199254, upload-time = "2026-04-24T00:12:04.239Z" }, + { url = "https://files.pythonhosted.org/packages/79/db/e28c1b83e3680740aa78925f5fb2ae4d16207207419ad75ea9fe604f8676/matplotlib-3.10.9-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8e436d155fa8a3399dc62683f8f5d0e2e50d25d0144a73edd73f82eec8f4abfb", size = 8777092, upload-time = "2026-04-24T00:12:06.793Z" }, + { url = "https://files.pythonhosted.org/packages/55/fa/3ce7adfe9ba101748f465211660d9c6374c876b671bdb8c2bb6d347e8b94/matplotlib-3.10.9-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:56fc0bd271b00025c6edfdc7c2dcd247372c8e1544971d62e1dc7c17367e8bf9", size = 9595691, upload-time = "2026-04-24T00:12:09.706Z" }, + { url = "https://files.pythonhosted.org/packages/36/c4/6960a76686ed668f2c60f84e9799ba4c0d56abdb36b1577b60c1d061d1ec/matplotlib-3.10.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a5a6104ed666402ba5106d7f36e0e0cdca4e8d7fa4d39708ca88019e2835a2eb", size = 9659771, upload-time = "2026-04-24T00:12:12.766Z" }, + { url = "https://files.pythonhosted.org/packages/7e/0d/271aace3342157c64700c9ff4c59c7b392f3dbab393692e8db6fbe7ab96c/matplotlib-3.10.9-cp311-cp311-win_amd64.whl", hash = "sha256:d730e984eddf56974c3e72b6129c7ca462ac38dc624338f4b0b23eb23ecba00f", size = 8205112, upload-time = "2026-04-24T00:12:15.773Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ee/cb57ad4754f3e7b9174ce6ce66d9205fb827067e48a9f58ac09d7e7d6b77/matplotlib-3.10.9-cp311-cp311-win_arm64.whl", hash = "sha256:51bf0ddbdc598e060d46c16b5590708f81a1624cefbaaf62f6a81bf9285b8c80", size = 8132310, upload-time = "2026-04-24T00:12:18.645Z" }, + { url = "https://files.pythonhosted.org/packages/35/c6/5581e26c72233ebb2a2a6fed2d24fb7c66b4700120b813f51b0555acf0b6/matplotlib-3.10.9-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f0c3c28d9fbcc1fe7a03be236d73430cf6409c41fb2383a7ac52fe932b072cb1", size = 8319908, upload-time = "2026-04-24T00:12:21.323Z" }, + { url = "https://files.pythonhosted.org/packages/b7/18/4880dd762e40cd360c1bf06e890c5a97b997e91cb324602b1a19950ad5ce/matplotlib-3.10.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:41cb28c2bd769aa3e98322c6ab09854cbcc52ab69d2759d681bba3e327b2b320", size = 8216016, upload-time = "2026-04-24T00:12:23.4Z" }, + { url = "https://files.pythonhosted.org/packages/32/91/d024616abdba99e83120e07a20658976f6a343646710760c4a51df126029/matplotlib-3.10.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ae20801130378b82d647ff5047c07316295b68dc054ca6b3c13519d0ea624285", size = 8789336, upload-time = "2026-04-24T00:12:26.096Z" }, + { url = "https://files.pythonhosted.org/packages/5c/04/030a2f61ef2158f5e4c259487a92ac877732499fb33d871585d89e03c42d/matplotlib-3.10.9-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6c63ebcd8b4b169eb2f5c200552ae6b8be8999a005b6b507ed76fb8d7d674fe2", size = 9604602, upload-time = "2026-04-24T00:12:29.052Z" }, + { url = "https://files.pythonhosted.org/packages/fc/c2/541e4d09d87bb6b5830fc28b4c887a9a8cf4e1c6cee698a8c05552ae2003/matplotlib-3.10.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d75d11c949914165976c621b2324f9ef162af7ebf4b057ddf95dd1dba7e5edcf", size = 9670966, upload-time = "2026-04-24T00:12:32.131Z" }, + { url = "https://files.pythonhosted.org/packages/04/a1/4571fc46e7702de8d0c2dc54ad1b2f8e29328dea3ee90831181f7353d93c/matplotlib-3.10.9-cp312-cp312-win_amd64.whl", hash = "sha256:d091f9d758b34aaaaa6331d13574bf01891d903b3dec59bfff458ef7551de5d6", size = 8217462, upload-time = "2026-04-24T00:12:35.226Z" }, + { url = "https://files.pythonhosted.org/packages/4b/d0/2269edb12aa30c13c8bcc9382892e39943ce1d28aab4ec296e0381798e81/matplotlib-3.10.9-cp312-cp312-win_arm64.whl", hash = "sha256:10cc5ce06d10231c36f40e875f3c7e8050362a4ee8f0ee5d29a6b3277d57bb42", size = 8136688, upload-time = "2026-04-24T00:12:37.442Z" }, + { url = "https://files.pythonhosted.org/packages/2c/2b/0e92ad0ac446633f928a1563db4aa8add407e1924faf0ded5b95b35afb27/matplotlib-3.10.9-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:1872fb212a05b729e649754a72d5da61d03e0554d76e80303b6f83d1d2c0552b", size = 8293058, upload-time = "2026-04-24T00:13:56.339Z" }, + { url = "https://files.pythonhosted.org/packages/4b/23/74682fd369f5299ceda438fea2a0662e6383b85c9383fb9cdfcf04713e07/matplotlib-3.10.9-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:985f2238880e2e69093f588f5fe2e46771747febf0649f3cf7f7b7480875317f", size = 8186627, upload-time = "2026-04-24T00:13:58.623Z" }, + { url = "https://files.pythonhosted.org/packages/ca/e8/368aab88f3c4cd8992800f31abfe0670c3e47540ba20a97e9fdbcde594b3/matplotlib-3.10.9-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6640f75af2c6148293caa0a2b39dd806a492dd66c8a8b04035813e33d0fd2585", size = 8764117, upload-time = "2026-04-24T00:14:01.684Z" }, + { url = "https://files.pythonhosted.org/packages/63/e2/9f66ca6a651a52abfe0d4964ce01439ed34f3f1e119de10ff3a07f403043/matplotlib-3.10.9-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:42fb814efabe95c06c1994d8ab5a8385f43a249e23badd3ba931d4308e5bca20", size = 8304420, upload-time = "2026-04-24T00:14:04.57Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e8/467c03568218792906aa87b5e7bb379b605e056ed0c74fe00c051786d925/matplotlib-3.10.9-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:f76e640a5268850bfda54b5131b1b1941cc685e42c5fa98ed9f2d64038308cba", size = 8197981, upload-time = "2026-04-24T00:14:07.233Z" }, + { url = "https://files.pythonhosted.org/packages/6f/87/afead29192170917537934c6aff4b008c805fff7b1ccea0c79120d96beda/matplotlib-3.10.9-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3fc0364dfbe1d07f6d15c5ebd0c5bf89e126916e5a8667dd4a7a6e84c36653d4", size = 8774002, upload-time = "2026-04-24T00:14:09.816Z" }, +] + +[[package]] +name = "matplotlib" +version = "3.11.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 = "contourpy", version = "1.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "cycler", marker = "python_full_version >= '3.11'" }, + { name = "fonttools", marker = "python_full_version >= '3.11'" }, + { name = "kiwisolver", 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", marker = "python_full_version >= '3.11'" }, + { name = "pillow", marker = "python_full_version >= '3.11'" }, + { name = "pyparsing", marker = "python_full_version >= '3.11'" }, + { name = "python-dateutil", marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/64/f9a391af28f518b11ad45a8a712353c94a0aefce09d3703200e5c54b610a/matplotlib-3.11.1.tar.gz", hash = "sha256:69647db5746941c793d6e445a4cd349323ffb87d9cc958c2ad84a659b4832d30", size = 32612045, upload-time = "2026-07-18T03:39:46.63Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6e/d0/791aa183dd88491555cf7d4be0b52b0bcf6c3c2a2c22c815a2e819bf53e2/matplotlib-3.11.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:b7cf158e7add54a8d51ac9b5a84abd6d4e13ed4951b4f25f1c5139f41c2addb2", size = 9440302, upload-time = "2026-07-18T03:38:03.844Z" }, + { url = "https://files.pythonhosted.org/packages/35/74/82bbdf683a301f4478384c8aaba6903631a2ca18294b2d7655c9a542bffb/matplotlib-3.11.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d2ace7273b9a5061a3b420918a16fae1f2dc5dfee1abcc13aba71b5d94b1820c", size = 9268549, upload-time = "2026-07-18T03:38:06.144Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f0/9b4298911303f74e6d83e64a81d996c0616405ec95046fac7f17e4258b9e/matplotlib-3.11.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aee55e9041211bf84302ab55ec3965df18dd90ae19f8b58332a7feaf208bfe83", size = 10024922, upload-time = "2026-07-18T03:38:08.236Z" }, + { url = "https://files.pythonhosted.org/packages/84/6f/0bc3c3d05b021db44c14bc379a7c0df7d57302aa15380c16fd4e63fd6a9b/matplotlib-3.11.1-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f4bdeea33a8d15a071dbfe6d119451b1d719c733ac666d65357082901a9099", size = 10832170, upload-time = "2026-07-18T03:38:10.276Z" }, + { url = "https://files.pythonhosted.org/packages/db/4d/e375f39acdb2af5a9342730618608e39790ec842e6f1b392863028781459/matplotlib-3.11.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b4c78ceb2f11bcac7389d305cda17aeb1f4586a857854ab5780bd3dd8dbfc407", size = 10916701, upload-time = "2026-07-18T03:38:12.512Z" }, + { url = "https://files.pythonhosted.org/packages/bc/be/fa26ed085b41298f64a8f9b7592c671bbf1acc8b0df124c1c5de96b859f8/matplotlib-3.11.1-cp311-cp311-win_amd64.whl", hash = "sha256:7f33a781e12b1e53b278deb2f5373c2e55ec4f10727be3440c0cfb5cda9f944f", size = 9315331, upload-time = "2026-07-18T03:38:14.949Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f3/eb5bdf3b6e191b200db298b08bbc1638b7f3c82cdc8680f9d88bf72559ae/matplotlib-3.11.1-cp311-cp311-win_arm64.whl", hash = "sha256:67e4c3cd578c65ebd81bdc09a1b6592ceafee6dfafe116dc85dfcb647b5bbb18", size = 9003475, upload-time = "2026-07-18T03:38:17.205Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6c/7ef7ebcb2bd9739b2b66b18b076e077f44bb46fdbe28ca0506edb3c62c79/matplotlib-3.11.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e15ef41507f3d525f46154ac9e3ae785dacde9f20e593a25de8986267892ef74", size = 9453849, upload-time = "2026-07-18T03:38:19.593Z" }, + { url = "https://files.pythonhosted.org/packages/eb/f8/6d0c312c8d9738e7d9677f09fe5c986b3239e651a7b73a2deb38b65e4a71/matplotlib-3.11.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:21a67b961a6d597bca54fae826cd20695ba4a6e4d05424a08da6e13e3176fd6b", size = 9283113, upload-time = "2026-07-18T03:38:21.95Z" }, + { url = "https://files.pythonhosted.org/packages/c9/cf/b4ad2cc81b6672ea29ea04e64e350a9f9b493b0908ccd884c67eeff8f7b2/matplotlib-3.11.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ba8f811b8ddfac493734d6af0b2dff96919d0c28ca0d641858dab4262777c6ea", size = 10035615, upload-time = "2026-07-18T03:38:24.315Z" }, + { url = "https://files.pythonhosted.org/packages/88/90/4e10e033d9b66589d8ed98b84c95cdbb57033d57c1f41339d7393dbd2f2e/matplotlib-3.11.1-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c52f7ad20ef476806ed212380b1d54d20310c8b86bdc2c9a68b51f0024a44472", size = 10842559, upload-time = "2026-07-18T03:38:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/88/eb/799612d0f8cd3e816a10fec59329fca52cd2353264df80378dfc541ae855/matplotlib-3.11.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8b14eb22961fe865efb0e4ff167e333e428908b00115a8d800ccb65ee108e481", size = 10927532, upload-time = "2026-07-18T03:38:28.532Z" }, + { url = "https://files.pythonhosted.org/packages/88/89/56649bbaa2fd12e20f3be03dbcc135b0c8676d88bac17977599e3eb442a0/matplotlib-3.11.1-cp312-cp312-win_amd64.whl", hash = "sha256:88a2a27dd9691ae448dfae4b26f59036be90c3c28757edd3553a29559d00859f", size = 9333886, upload-time = "2026-07-18T03:38:30.477Z" }, + { url = "https://files.pythonhosted.org/packages/c1/11/4d124efbbad677b7b7552f6f85a3bd432d4232f95400cea98fcd2ae36ef3/matplotlib-3.11.1-cp312-cp312-win_arm64.whl", hash = "sha256:480194afceca4df2f137c2721227d3cba67121fbf4397b69cee7f83714b0a58a", size = 9007545, upload-time = "2026-07-18T03:38:32.833Z" }, + { url = "https://files.pythonhosted.org/packages/ee/38/ceb1d637c4db6d06141f3739e93af3321e7caaabe69b57ae48ffe3ee95b1/matplotlib-3.11.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:427258425f9a3fc4ed79a91f9e9b9aaf5a82cb6571e85dc14063cc6fbb993741", size = 9438045, upload-time = "2026-07-18T03:39:39.491Z" }, + { url = "https://files.pythonhosted.org/packages/89/25/72ad8b58602d3a6ef1dfc4b65ecd01634ab65a2bdf494c9fe0e966dbf081/matplotlib-3.11.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:1ac697e591c11b6ad04679a73c2d2f9980fe9d9f0311fb414a2e329706343dfb", size = 9266127, upload-time = "2026-07-18T03:39:41.597Z" }, + { url = "https://files.pythonhosted.org/packages/8a/6d/69552382fcc8e93d1f2763ef2665980a900a48b7f3a4c57ed290726d1cbc/matplotlib-3.11.1-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e4b9ac2f1f607ecda2af90a5232beee2af7582fce1cc30c4b6a1b012dc21ee99", size = 10019439, upload-time = "2026-07-18T03:39:43.78Z" }, +] + [[package]] name = "matplotlib-inline" version = "0.2.1" @@ -978,7 +1277,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 +1307,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 = [ @@ -1194,6 +1495,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/56/79/12978b96bd44274fe38b5dde5cfb660b1d114f70a65ef962bcbbed99b549/nvidia_cusparselt_cu12-0.7.1-py3-none-manylinux2014_x86_64.whl", hash = "sha256:f1bb701d6b930d5a7cea44c19ceb973311500847f81b634d802b7b539dc55623", size = 287193691, upload-time = "2025-02-26T00:15:44.104Z" }, ] +[[package]] +name = "nvidia-ml-py" +version = "13.610.43" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f0/b5/a8fbc356f768fa5c9cfd646668fd7d34bf55bdd1c6e20754642a64d930d4/nvidia_ml_py-13.610.43.tar.gz", hash = "sha256:65437eb73d68d0c62c931ca4d45038472faff03bd0b8729abba4b899f70d60f2", size = 52109, upload-time = "2026-06-01T18:54:08.829Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/23/45/caa600acfab94560807a20a64b5830d2cd3c3202b7f1328644d70b7d6bd8/nvidia_ml_py-13.610.43-py3-none-any.whl", hash = "sha256:f13c72698edef492f985cc225f14faafe68ae065a2e407f45bdf6f4b9b43fde8", size = 53163, upload-time = "2026-06-01T18:54:07.704Z" }, +] + [[package]] name = "nvidia-nccl-cu12" version = "2.27.5" @@ -1226,6 +1536,26 @@ 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 = "5.0.0.93" +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'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/79/4c/a438d23e09ce2033c09f7b784ad2fbdb0adf529e434101ed28f142226f98/opencv_python-5.0.0.93.tar.gz", hash = "sha256:66aac3e5b5faa48d4025816592f3af19e4bfc2c68dec067bae2dbb4ca10aa9e2", size = 81802749, upload-time = "2026-07-02T06:59:53.815Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/75/76f6ade78f6102c61034f828e2a22616708df2c9504bc8d6af9dd8f73dc5/opencv_python-5.0.0.93-cp37-abi3-macosx_13_0_arm64.whl", hash = "sha256:198a75138241810206a17c829dbcc40a7cb1841cda538ca86cbbfc6c7d95f898", size = 48322443, upload-time = "2026-07-02T05:50:25.466Z" }, + { url = "https://files.pythonhosted.org/packages/15/8c/bc1bda6aae69a32e9d84fc34153ba104cd25226861eb4aea33b2cea4860d/opencv_python-5.0.0.93-cp37-abi3-macosx_14_0_x86_64.whl", hash = "sha256:6bbc32f59e1b1a7db7b39c81f63d00625f041d333037fd8702f6da52cc39108b", size = 34782755, upload-time = "2026-07-02T05:51:30.556Z" }, + { url = "https://files.pythonhosted.org/packages/f4/8a/b04776ec45d2dea08a1b176f1829201db3515d4ed16c35f8fcc9fa7beb16/opencv_python-5.0.0.93-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e2b4272e736836f66c2d176e43ab8101f3a00d45654916399f52e150c58981ac", size = 50614064, upload-time = "2026-07-02T06:53:22.604Z" }, + { url = "https://files.pythonhosted.org/packages/95/54/eb47866b94f2b5b42dde17644b78055ef1ee05aae59962c7290e55270803/opencv_python-5.0.0.93-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f8b6d0a212253dd26ad338c812f1f23ca118fdf05a9c8c6b9444f161aa8c5881", size = 71064711, upload-time = "2026-07-02T06:54:13.148Z" }, + { url = "https://files.pythonhosted.org/packages/93/da/962579f1e703cbf8c5422fd1f576467dcb3b5b0b0b81c1471c979764353a/opencv_python-5.0.0.93-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:08d5d91d967b58d6db86073b2ad3eaef88ca4ebdfd45c9059bf59f5ded0c7ad2", size = 49798576, upload-time = "2026-07-02T06:54:33.781Z" }, + { url = "https://files.pythonhosted.org/packages/cf/4c/c73f828fdbcd37eaf21d08fa852544a3ca7c2dbb3ea76873d64f2ea413d1/opencv_python-5.0.0.93-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:c8de2dec111122a02e8beb28e16c31904992dfd6186560b142a92c71403c1039", size = 73783032, upload-time = "2026-07-02T06:55:03.415Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4b/edaf83b996ca5a1a3d8ccad485706b9c6d4742b13b9c4586bf1c1e7d9423/opencv_python-5.0.0.93-cp37-abi3-win32.whl", hash = "sha256:4b4b1a34c79bf8d3738e3cfe9a9e67b51a79663f6b692cbdad8c31f570da4157", size = 35564734, upload-time = "2026-07-02T05:49:57.704Z" }, + { url = "https://files.pythonhosted.org/packages/21/f0/9fa6e85cb10c8eb36a0222d27e50fe381b86ce49a55446bf39f491727564/opencv_python-5.0.0.93-cp37-abi3-win_amd64.whl", hash = "sha256:f90ba04b8f73bc5c3814037699739f0156f597338a98f05956c684e7c3ca10d2", size = 44000345, upload-time = "2026-07-02T05:49:54.971Z" }, +] + [[package]] name = "orjson" version = "3.11.7" @@ -1291,7 +1621,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 +1715,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 +1797,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.43.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "polars-runtime-32" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/5b/5d0f0aa53c6e9a8ecbc99ff502edcf9584e5d08ab34ea407c086999103d5/polars-1.43.0.tar.gz", hash = "sha256:bb2c67553e4968c18dfe268a88ff9a5790d5c2e0b7ea7efe97640b9a90438c88", size = 749537, upload-time = "2026-07-21T04:30:25.966Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/28/a8eac2c1d1b2d2a4ba2eb745921616d863185d94b1afd91cbb07af9ef21a/polars-1.43.0-py3-none-any.whl", hash = "sha256:c49078b14e2d6b8ff5cc5b78b6d9638603ea5dffafb889d9204818822f55b813", size = 846493, upload-time = "2026-07-21T04:29:06.68Z" }, +] + +[[package]] +name = "polars-runtime-32" +version = "1.43.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/86/96/7e714cad082e9e6aaebb8886fcb1b0220d5c35149d4c8d3466bd7e7d581e/polars_runtime_32-1.43.0.tar.gz", hash = "sha256:5fb47a3a883402e62eab2fde5922f78c531d037aeece3640c15225f39228621e", size = 3090044, upload-time = "2026-07-21T04:30:27.185Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/14/9b1f5eb1c5104ba1ceb380a7308ffcd979f3ce1df86e76293062698f2ff1/polars_runtime_32-1.43.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:6707193d30a7135bce0424304f76d8145270527444097548e794ad8d26823b70", size = 53059463, upload-time = "2026-07-21T04:29:09.194Z" }, + { url = "https://files.pythonhosted.org/packages/ca/14/73d77d1c0c928eb599d9516d874af0cd2b6225201e1327a6c4857e6776d0/polars_runtime_32-1.43.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:78ca2f97740b2a6beb36eb112749280b5e08750c60f53c08d2feffebdba9d35a", size = 47499586, upload-time = "2026-07-21T04:29:13.229Z" }, + { url = "https://files.pythonhosted.org/packages/80/b1/98278fa796f93d0975fd3fe1d4ab4031707d4a9f1da44c21c29996b62c73/polars_runtime_32-1.43.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4ffa99bb2c7ee0a9392ae50b350af0ed17acf0519d15c75fe223021798566174", size = 51326702, upload-time = "2026-07-21T04:29:16.084Z" }, + { url = "https://files.pythonhosted.org/packages/ab/7d/24ae73389aac03296925973c4e2cbe2e4982e859b9fad69eb1a72b9026fa/polars_runtime_32-1.43.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ecc8feaf04de5989a29245885921db612ef1ce9065e5cb6ec37495acfa55bba", size = 57266705, upload-time = "2026-07-21T04:29:19.288Z" }, + { url = "https://files.pythonhosted.org/packages/5c/49/2026be1f7b51242ad62e08b20728462558e161fb84b564e5b986f2b38664/polars_runtime_32-1.43.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:01e1471a5ee161a969c7a96991d8e5d20b97a0b7fe075df9c7a01f52a49c5ac2", size = 51484129, upload-time = "2026-07-21T04:29:22.639Z" }, + { url = "https://files.pythonhosted.org/packages/db/e2/047c7695f08a9b18614c0e1ec5e70a754455542a21a6d52955f7f05c6268/polars_runtime_32-1.43.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9cd8b813afe67d59e87027dedcaf5c6a06fe602472fd74e1a49f4bafea47259c", size = 55169401, upload-time = "2026-07-21T04:29:25.902Z" }, + { url = "https://files.pythonhosted.org/packages/92/dc/bfd2533c487563c7a21ab7d7af3d78f820b0236e14dc5ee63d46188cd275/polars_runtime_32-1.43.0-cp310-abi3-win_amd64.whl", hash = "sha256:41a75fb3cb4cc574eb21801383578f75cfc374597c22322ef457ab7bac8a3301", size = 52541527, upload-time = "2026-07-21T04:29:29.162Z" }, + { url = "https://files.pythonhosted.org/packages/46/d7/5c47f1bf57479d1671669af9c421f9abf4986b2ddaa64c177244ff811de0/polars_runtime_32-1.43.0-cp310-abi3-win_arm64.whl", hash = "sha256:c285e598dd91e08560e519275b8b8108adbafb438d218a175ebe073dbc2027fb", size = 46552281, upload-time = "2026-07-21T04:29:32.224Z" }, +] + [[package]] name = "prompt-toolkit" version = "3.0.52" @@ -1478,6 +1837,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 +2064,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 = [ @@ -2173,6 +2557,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 +2649,7 @@ dependencies = [ { name = "torch" }, { name = "torchvision" }, { name = "typer" }, + { name = "ultralytics" }, { name = "uvicorn" }, ] @@ -2316,6 +2704,7 @@ requires-dist = [ { name = "torch", specifier = ">=2.5" }, { name = "torchvision", specifier = ">=0.20" }, { name = "typer", specifier = ">=0.12.3,<1" }, + { name = "ultralytics", specifier = ">=8.4.0" }, { name = "uvicorn", specifier = ">=0.20" }, ] provides-extras = ["gui", "postgres"] @@ -2399,6 +2788,46 @@ 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.104" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock" }, + { name = "matplotlib", version = "3.10.9", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "matplotlib", version = "3.11.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { 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 = "nvidia-ml-py" }, + { name = "opencv-python" }, + { name = "pillow" }, + { name = "polars" }, + { name = "psutil" }, + { name = "pyyaml" }, + { name = "requests" }, + { name = "torch" }, + { name = "torchvision" }, + { name = "ultralytics-thop" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d3/26/2cc07abfb79b81dac907b88559f732840cabb47c6c92b2fd722a709a1a95/ultralytics-8.4.104.tar.gz", hash = "sha256:98e15ec09a917f1baacd531a4048ea8c6d54d70162accae51e5b532b573b56b8", size = 1191902, upload-time = "2026-07-21T19:42:14.063Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/15/543669b21ab8dc8a882cc872620eef7ed40343d7089ac66a8d448645ed5f/ultralytics-8.4.104-py3-none-any.whl", hash = "sha256:2625e73863b06dd882b4024b7a3a033ce712d200e3de58af5252939fb74aa403", size = 1413385, upload-time = "2026-07-21T19:41:49.038Z" }, +] + +[[package]] +name = "ultralytics-thop" +version = "2.0.20" +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/98/c6/d25cb53e141242f74950744179c21b8798bb09b9e7161465ecda4f577ddf/ultralytics_thop-2.0.20.tar.gz", hash = "sha256:f3595e0d8c6fd0b9f62fc2cd9be921755e2649a05c34f1fabaea0bff7295d641", size = 34682, upload-time = "2026-06-06T11:42:42.184Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/a9/5441a28af447c0ffe193f1f42dfebe2a518ab3e160e9e0d164e8901edead/ultralytics_thop-2.0.20-py3-none-any.whl", hash = "sha256:ef1b326404aeb10954f50db58982ec8dcab5946ee04a9b10e1107ec7ba4d5c22", size = 28954, upload-time = "2026-06-06T11:42:40.796Z" }, +] + [[package]] name = "urllib3" version = "2.6.3"