diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..710de14 --- /dev/null +++ b/.envrc @@ -0,0 +1,6 @@ +# Use pixi for environment management +watch_file pixi.lock +eval "$(pixi shell-hook -e dev)" + +# Unset ROS environment variables to avoid conflicts +source scripts/unset_ros2_env.sh diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..e6dfd3f --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,41 @@ +name: Test + +on: + pull_request: {} + push: + branches: main + +jobs: + test: + strategy: + matrix: + python-version: ['3.12'] + os: [ubuntu-latest] + + name: Python ${{ matrix.os }} ${{ matrix.python-version }} + runs-on: ${{ matrix.os }} + + steps: + - uses: actions/checkout@v5 + + - uses: actions/setup-python@v6 + with: + python-version: ${{ matrix.python-version }} + + - uses: prefix-dev/setup-pixi@v0.9.1 + with: + pixi-version: v0.63.2 + cache: true + environments: dev + + - name: Download hand landmarker model + run: | + mkdir -p models + curl -L -o models/hand_landmarker.task \ + https://storage.googleapis.com/mediapipe-models/hand_landmarker/hand_landmarker/float16/1/hand_landmarker.task + + - run: pixi run fmt + - run: pixi run lint + - run: pixi run types + - name: Run tests + run: pixi run test -m "not slow" -v --tb=short --timeout=60 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7606faf --- /dev/null +++ b/.gitignore @@ -0,0 +1,36 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so + +# Distribution / packaging +build/ +dist/ +*.egg-info/ +*.egg + +# Virtual environments +.env +.venv +venv/ +ENV/ + +# Testing +.pytest_cache/ +.cache + +# Ruff +.ruff_cache/ + +# Pixi +.pixi + +# Models (large files) +models/*.task +!models/.gitkeep + +# Data files (large binary files) +# Ignore .jpg images in data subfolders, but allow .zip files +# Allow .npz files (processed features are small and useful for reproducibility) +data/**/*.jpg diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..9fc1468 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,17 @@ +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.15.0 + hooks: + - id: ruff + args: [--fix] + - id: ruff-format + + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-added-large-files + args: ['--maxkb=20000'] + exclude: '\.zip$' diff --git a/README.md b/README.md index 7fb3d90..642c431 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,6 @@ -# motionhand +# handmotion + Hand Gesture Classifier + +![License](https://img.shields.io/badge/license-Apache%202.0-blue) +[![Powered by: Pixi](https://img.shields.io/badge/Powered_by-Pixi-facc15)](https://pixi.sh) diff --git a/data/processed/rps/data.npz b/data/processed/rps/data.npz new file mode 100644 index 0000000..70b5ca5 Binary files /dev/null and b/data/processed/rps/data.npz differ diff --git a/data/raw/rps.zip b/data/raw/rps.zip new file mode 100644 index 0000000..c8a7dee Binary files /dev/null and b/data/raw/rps.zip differ diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md new file mode 100644 index 0000000..af0d19c --- /dev/null +++ b/docs/DEVELOPMENT.md @@ -0,0 +1,61 @@ +# Development Guide + +## Development Workflow + +### Initial Setup + +```bash +# Install dependencies +pixi install + +# Activate the dev environment (or use direnv for automatic activation) +pixi shell -e dev +``` + +### Common Tasks + +```bash +# pre-commit check +pixi run pre-commit + +# Run tests +pixi run test + +# Run all checks (format, lint, types, test) +pixi run all +``` + +### Adding Dependencies + +```bash +# Add a conda dependency +pixi add + +# Add a PyPI dependency +pixi add --pypi + +# Add a dev dependency +pixi add --dev +``` +## Environment Setup + +The project uses `.envrc` for automatic environment activation via direnv. + +### What is `.envrc`? + +The `.envrc` file is used by direnv to automatically load environment variables when you enter the project directory. + +**Contents:** +- `watch_file pixi.lock` - Tells direnv to reload when `pixi.lock` changes (e.g., after adding dependencies) +- `eval "$(pixi shell-hook -e dev)"` - Activates the pixi dev environment, setting up the Python environment, PATH, and other variables + +**How it works:** +- When you `cd` into the project directory, direnv automatically activates the pixi dev environment +- When you leave, it deactivates +- No need to manually run `pixi shell` or activate virtual environments + +**Setup required:** +- Install `direnv` and add it to your shell (usually a one-time setup) +- Run `direnv allow` once in the project directory to trust the `.envrc` file + +This keeps your development environment activated automatically as you work. diff --git a/handmotion/__init__.py b/handmotion/__init__.py new file mode 100644 index 0000000..64e40a0 --- /dev/null +++ b/handmotion/__init__.py @@ -0,0 +1,3 @@ +"""Hand gesture classifier package.""" + +__version__ = "0.0.1" diff --git a/handmotion/data/feature_extractor.py b/handmotion/data/feature_extractor.py new file mode 100644 index 0000000..3c7d1c3 --- /dev/null +++ b/handmotion/data/feature_extractor.py @@ -0,0 +1,251 @@ +# Copyright (C) 2026 Julia Jia +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Feature extraction from hand images using MediaPipe.""" + +from typing import Optional, TypedDict + +import numpy as np +from mediapipe import Image, ImageFormat +from mediapipe.tasks import python +from mediapipe.tasks.python import vision + + +class HandFeatures(TypedDict): + landmarks: np.ndarray + finger_states: np.ndarray + handedness: Optional[str] + + +class FeatureExtractor: + """Extract hand landmarks and finger states from images.""" + + # MediaPipe hand landmark indices + WRIST = 0 + THUMB_TIP = 4 + THUMB_IP = 3 + INDEX_TIP = 8 + INDEX_MCP = 5 + MIDDLE_TIP = 12 + MIDDLE_FINGER_MCP = 9 + MIDDLE_MCP = 9 + RING_TIP = 16 + RING_MCP = 13 + PINKY_TIP = 20 + PINKY_MCP = 17 + + def __init__(self, model_path=None): + """ + Initialize MediaPipe hand landmarker. + + Args: + model_path: Path to hand landmarker model file (.task). + If None, uses bundled model from mediapipe package. + """ + from pathlib import Path + + if model_path is None: + # Try to use bundled model or download default + # Check if model exists in project models directory + project_root = Path(__file__).parent.parent.parent + model_file = project_root / "models" / "hand_landmarker.task" + if model_file.exists(): + model_path = str(model_file) + else: + # Use mediapipe's bundled model path + import mediapipe + + mediapipe_path = Path(mediapipe.__file__).parent + bundled_model = mediapipe_path / "tasks" / "models" / "hand_landmarker.task" + if bundled_model.exists(): + model_path = str(bundled_model) + else: + raise FileNotFoundError( + "Hand landmarker model not found. " + "Please download from: " + "https://storage.googleapis.com/mediapipe-models/hand_landmarker/hand_landmarker/float16/1/hand_landmarker.task" + ) + + base_options = python.BaseOptions(model_asset_path=model_path) + options = vision.HandLandmarkerOptions( + base_options=base_options, + num_hands=1, + min_hand_detection_confidence=0.5, + min_hand_presence_confidence=0.5, + min_tracking_confidence=0.5, + ) + self.hand_landmarker = vision.HandLandmarker.create_from_options(options) + + def close(self): + """Close MediaPipe hand landmarker and release resources.""" + if hasattr(self, "hand_landmarker") and self.hand_landmarker is not None: + try: + self.hand_landmarker.close() + except Exception: + pass # Ignore errors during cleanup + self.hand_landmarker = None + + def __enter__(self): + """Context manager entry.""" + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + """Context manager exit - ensures cleanup.""" + self.close() + + def extract(self, image, image_format="rgb") -> Optional[HandFeatures | None]: + """ + Extract features from image. + + Args: + image: Image as numpy array or PIL Image. + image_format: 'rgb' or 'bgr'. Only used for numpy arrays. + PIL Images are always treated as RGB. + + Returns: + Dict with 'landmarks', 'finger_states', 'handedness', or None if no hand detected. + """ + # Convert to RGB numpy array if needed + if isinstance(image, np.ndarray) and len(image.shape) == 3: + if image_format == "bgr": + try: + import cv2 + + image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB) + except ImportError as err: + raise ImportError( + "OpenCV (cv2) is required for BGR to RGB conversion. " + "Install with: pip install opencv-python" + ) from err + # Convert numpy array to MediaPipe Image + mp_image = Image(image_format=ImageFormat.SRGB, data=image) + else: + # PIL Image - convert to numpy array first + if hasattr(image, "mode") and image.mode != "RGB": + image = image.convert("RGB") + image_array = np.array(image) + mp_image = Image(image_format=ImageFormat.SRGB, data=image_array) + + # Detect hand landmarks + detection_result = self.hand_landmarker.detect(mp_image) + + if not detection_result.hand_landmarks: + return None + + # Get first hand (we only process one hand) + hand_landmarks = detection_result.hand_landmarks[0] + handedness = None + if detection_result.handedness and len(detection_result.handedness) > 0: + # Handedness is List[List[Category]] - first list is per hand, second is categories + hand_categories = detection_result.handedness[0] + if hand_categories and len(hand_categories) > 0: + handedness = hand_categories[0].category_name + + # Convert landmarks to numpy array + landmarks = np.array([[lm.x, lm.y, lm.z] for lm in hand_landmarks]) + + # Normalize landmarks + normalized_landmarks = self.normalize(landmarks) + + # Get finger states + finger_states = self._get_finger_states(normalized_landmarks) + + return { + "landmarks": normalized_landmarks, + "finger_states": finger_states, + "handedness": handedness, + } + + def normalize(self, landmarks): + """ + Normalize landmarks to wrist-centered coordinates. + + Args: + landmarks: Array of shape (21, 3) with x, y, z coordinates + + Returns: + Normalized landmarks array + """ + wrist = landmarks[self.WRIST].copy() + + # Translate to wrist-centered + normalized = landmarks - wrist + + # Scale by distance from wrist to middle finger MCP joint + # to normalize for hand size + scale = np.linalg.norm(normalized[self.MIDDLE_FINGER_MCP]) + if scale > 0: + normalized = normalized / scale + + return normalized + + def _get_finger_states(self, landmarks): + """ + Encode finger extended/curled states. + + Args: + landmarks: Normalized landmarks array of shape (21, 3) + + Returns: + Array of 5 booleans: [thumb, index, middle, ring, pinky] + True = extended, False = curled + """ + finger_states = [] + + # Thumb: extended if tip is further from wrist than IP joint + thumb_tip_dist = np.linalg.norm(landmarks[self.THUMB_TIP] - landmarks[self.WRIST]) + thumb_ip_dist = np.linalg.norm(landmarks[self.THUMB_IP] - landmarks[self.WRIST]) + finger_states.append(thumb_tip_dist > thumb_ip_dist) + + # Other fingers: extended if tip is above MCP (higher y value in normalized coords) + for tip_idx, mcp_idx in [ + (self.INDEX_TIP, self.INDEX_MCP), + (self.MIDDLE_TIP, self.MIDDLE_MCP), + (self.RING_TIP, self.RING_MCP), + (self.PINKY_TIP, self.PINKY_MCP), + ]: + # In MediaPipe normalized coordinates, y increases downward + # So finger is extended if tip y < mcp y (tip is "above" mcp) + finger_states.append(landmarks[tip_idx][1] < landmarks[mcp_idx][1]) + + return np.array(finger_states, dtype=bool) + + @staticmethod + def format_features(features: Optional[HandFeatures]) -> str: + """ + Format extracted features as a human-readable string for debugging. + + Args: + features: HandFeatures dict or None + + Returns: + Formatted string representation + """ + if features is None: + return "No hand detected" + + finger_names = ["thumb", "index", "middle", "ring", "pinky"] + finger_states_str = ", ".join( + f"{name}: {'extended' if state else 'curled'}" + for name, state in zip(finger_names, features["finger_states"], strict=False) + ) + + handedness_str = features["handedness"] or "unknown" + landmarks_shape = features["landmarks"].shape + + return ( + f"Handedness: {handedness_str}\n" + f"Finger states: {finger_states_str}\n" + f"Landmarks shape: {landmarks_shape}" + ) diff --git a/handmotion/data/processor.py b/handmotion/data/processor.py new file mode 100644 index 0000000..e7a31d2 --- /dev/null +++ b/handmotion/data/processor.py @@ -0,0 +1,146 @@ +# Copyright (C) 2026 Julia Jia +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Process image folders and extract features.""" + +import logging +from pathlib import Path + +import numpy as np +from PIL import Image + +from handmotion.data.feature_extractor import FeatureExtractor + +logger = logging.getLogger(__name__) + + +class DataProcessor: + """Process images from folders and save extracted features.""" + + def __init__(self): + """Initialize feature extractor.""" + self.extractor = FeatureExtractor() + + def process_folder(self, folder_path, label): + """ + Process all images in folder and extract features. + + Args: + folder_path: Path to folder containing images + label: Label for images in this folder + + Returns: + List of (features, label) tuples + """ + folder = Path(folder_path) + if not folder.exists(): + raise ValueError(f"Folder does not exist: {folder_path}") + + # Supported image extensions + image_extensions = {".jpg", ".jpeg", ".png", ".bmp", ".tiff", ".tif"} + + # Find all image files + image_files = [ + f for f in folder.iterdir() if f.suffix.lower() in image_extensions and f.is_file() + ] + + if not image_files: + logger.warning(f"No image files found in {folder_path}") + return [] + + results = [] + failed_images = [] + + for image_file in image_files: + try: + # Load image using PIL (always RGB) + image = Image.open(image_file) + # Convert to RGB if needed (handles RGBA, L, etc.) + if image.mode != "RGB": + image = image.convert("RGB") + + # Extract features + features = self.extractor.extract(image, image_format="rgb") + + if features is None: + failed_images.append(str(image_file)) + logger.debug(f"No hand detected in {image_file}") + continue + + results.append((features, label)) + + except Exception as e: + failed_images.append(str(image_file)) + logger.warning(f"Failed to process {image_file}: {e}") + + if failed_images: + logger.info(f"Failed to process {len(failed_images)} images: {failed_images}") + + logger.info(f"Processed {len(results)}/{len(image_files)} images from {folder_path}") + + return results + + def save(self, data, output_path): + """ + Save processed data to npz file. + + Args: + data: List of (features, label) tuples + output_path: Path to save the npz file + """ + if not data: + raise ValueError("Cannot save empty data") + + output_path = Path(output_path) + output_path.parent.mkdir(parents=True, exist_ok=True) + + # Separate features and labels + features_list = [item[0] for item in data] + labels_list = [item[1] for item in data] + + # Convert to arrays for training + # Stack landmarks and finger_states + landmarks_array = np.array([f["landmarks"] for f in features_list]) + finger_states_array = np.array([f["finger_states"] for f in features_list]) + handedness_list = [f["handedness"] for f in features_list] + + # Save as npz + np.savez( + output_path, + # Array format for training + landmarks=landmarks_array, + finger_states=finger_states_array, + handedness=handedness_list, + labels=labels_list, + # Keep tuple format for debugging (as metadata) + labels_list=labels_list, + ) + + logger.info(f"Saved {len(data)} samples to {output_path}") + + +def main(): + """Main function to process data.""" + processor = DataProcessor() + cwd = Path.cwd() + + results = processor.process_folder(cwd / "data" / "raw" / "rps" / "rock", "rock") + results.extend(processor.process_folder(cwd / "data" / "raw" / "rps" / "paper", "paper")) + results.extend(processor.process_folder(cwd / "data" / "raw" / "rps" / "scissors", "scissors")) + processor.save(results, cwd / "data" / "processed" / "rps" / "data.npz") + print(f"Processed {len(results)} samples") + + +if __name__ == "__main__": + main() diff --git a/handmotion/model.py b/handmotion/model.py new file mode 100644 index 0000000..2bd58d9 --- /dev/null +++ b/handmotion/model.py @@ -0,0 +1,218 @@ +# Copyright (C) 2026 Julia Jia +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Model for classifying hand gestures using scikit-learn.""" + +import logging +from pathlib import Path + +import joblib +import numpy as np +from sklearn.ensemble import RandomForestClassifier +from sklearn.model_selection import train_test_split +from sklearn.preprocessing import LabelEncoder + +logger = logging.getLogger(__name__) + + +class HandGestureClassifier: + """Classifier for rock-paper-scissors hand gestures.""" + + def __init__(self, classifier=None): + """ + Initialize the classifier. + + Args: + classifier: scikit-learn classifier instance. If None, uses RandomForestClassifier. + RandomForestClassifier: Good for fixed-size feature vectors with non-linear + relationships, provides feature importance, no scaling needed. Limitations: + Less interpretable than simple models (e.g., LogisticRegression with + interpretable coefficients), can overfit with many trees, slower than + linear models. + """ + if classifier is None: + self.classifier = RandomForestClassifier(random_state=42) + else: + self.classifier = classifier + + self.label_encoder = LabelEncoder() + self.is_trained = False + + def _prepare_features(self, landmarks, finger_states): + """ + Combine landmarks and finger_states into a single feature vector. + + Args: + landmarks: Array of shape (n_samples, 21, 3) or (21, 3) + finger_states: Array of shape (n_samples, 5) or (5,) + + Returns: + Feature array of shape (n_samples, 68) or (68,) + """ + # Flatten landmarks: (n_samples, 21, 3) -> (n_samples, 63) + if landmarks.ndim == 3: + landmarks_flat = landmarks.reshape(landmarks.shape[0], -1) + else: + landmarks_flat = landmarks.reshape(1, -1) + + # Ensure finger_states is 2D + if finger_states.ndim == 1: + finger_states = finger_states.reshape(1, -1) + + # Combine: (n_samples, 63) + (n_samples, 5) -> (n_samples, 68) + features = np.hstack([landmarks_flat, finger_states]) + + return features + + def load_data(self, data_path): + """ + Load data from npz file. + + Args: + data_path: Path to the npz file + + Returns: + Tuple of (features, labels) where features is (n_samples, 68) and labels is (n_samples,) + """ + data_path = Path(data_path) + if not data_path.exists(): + raise FileNotFoundError(f"Data file not found: {data_path}") + + data = np.load(data_path, allow_pickle=True) + landmarks = data["landmarks"] + finger_states = data["finger_states"] + labels = data["labels"] + + features = self._prepare_features(landmarks, finger_states) + labels = np.array(labels) + + logger.info(f"Loaded {len(features)} samples from {data_path}") + return features, labels + + def train(self, features, labels, test_size=0.2, random_state=42): + """ + Train the classifier. + + Args: + features: Feature array of shape (n_samples, 68) + labels: Label array of shape (n_samples,) + test_size: Proportion of data to use for testing + random_state: Random state for train_test_split + + Returns: + Tuple of (X_train, X_test, y_train, y_test) + """ + # Encode labels + labels_encoded = self.label_encoder.fit_transform(labels) + + # Split data + X_train, X_test, y_train, y_test = train_test_split( + features, + labels_encoded, + test_size=test_size, + random_state=random_state, + stratify=labels_encoded, + ) + + # Train classifier + self.classifier.fit(X_train, y_train) + self.is_trained = True + + logger.info(f"Trained classifier on {len(X_train)} samples") + return X_train, X_test, y_train, y_test + + def predict(self, features): + """ + Predict labels for given features. + + Args: + features: Feature array of shape (n_samples, 68) or (68,) + + Returns: + Predicted labels as strings + """ + if not self.is_trained: + raise ValueError("Classifier must be trained before prediction") + + # Ensure features are 2D + if features.ndim == 1: + features = features.reshape(1, -1) + + # Predict + predictions_encoded = self.classifier.predict(features) + predictions = self.label_encoder.inverse_transform(predictions_encoded) + + return predictions + + def predict_proba(self, features): + """ + Predict class probabilities for given features. + + This provides confidence scores (e.g., [0.9, 0.05, 0.05] vs [0.4, 0.35, 0.25]), + enables uncertainty detection when probabilities are close, allows threshold-based + rejection of low-confidence predictions, and helps with debugging model behavior. + + Args: + features: Feature array of shape (n_samples, 68) or (68,) + + Returns: + Probability array of shape (n_samples, n_classes) + """ + if not self.is_trained: + raise ValueError("Classifier must be trained before prediction") + + # Ensure features are 2D + if features.ndim == 1: + features = features.reshape(1, -1) + + return self.classifier.predict_proba(features) + + def save(self, model_path): + """ + Save the trained model to disk. + + Args: + model_path: Path to save the model + """ + if not self.is_trained: + raise ValueError("Cannot save untrained model") + + model_path = Path(model_path) + model_path.parent.mkdir(parents=True, exist_ok=True) + + model_data = { + "classifier": self.classifier, + "label_encoder": self.label_encoder, + } + + joblib.dump(model_data, model_path) + logger.info(f"Saved model to {model_path}") + + def load(self, model_path): + """ + Load a trained model from disk. + + Args: + model_path: Path to the saved model + """ + model_path = Path(model_path) + if not model_path.exists(): + raise FileNotFoundError(f"Model file not found: {model_path}") + + model_data = joblib.load(model_path) + self.classifier = model_data["classifier"] + self.label_encoder = model_data["label_encoder"] + self.is_trained = True + + logger.info(f"Loaded model from {model_path}") diff --git a/handmotion/predict.py b/handmotion/predict.py new file mode 100644 index 0000000..4c1fe74 --- /dev/null +++ b/handmotion/predict.py @@ -0,0 +1,141 @@ +# Copyright (C) 2026 Julia Jia +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Prediction script for hand gesture classifier. + +Examples: + # Predict on a single image + python -m handmotion.predict --model models/rps_classifier.joblib --image path/to/image.jpg + + # Predict with confidence scores + python -m handmotion.predict \ + --model models/rps_classifier.joblib \ + --image path/to/image.jpg --show-proba +""" + +import argparse +import logging +from pathlib import Path + +from PIL import Image + +from handmotion.data.feature_extractor import FeatureExtractor +from handmotion.model import HandGestureClassifier + +logging.basicConfig( + level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" +) +logger = logging.getLogger(__name__) + + +def predict_image(classifier, extractor, image_path): + """ + Predict gesture for a single image. + + Args: + classifier: Loaded HandGestureClassifier + extractor: FeatureExtractor instance + image_path: Path to image file + + Returns: + Tuple of (predicted_label, confidence_dict) or (None, None) if no hand detected + """ + # Load image + image = Image.open(image_path) + if image.mode != "RGB": + image = image.convert("RGB") + + # Extract features + features_dict = extractor.extract(image, image_format="rgb") + if features_dict is None: + logger.warning(f"No hand detected in {image_path}") + return None, None + + # Prepare features for classifier + landmarks = features_dict["landmarks"].reshape(1, -1) # (1, 63) + finger_states = features_dict["finger_states"].reshape(1, -1) # (1, 5) + features = classifier._prepare_features(landmarks, finger_states) # (1, 68) + + # Predict + prediction = classifier.predict(features)[0] + probabilities = classifier.predict_proba(features)[0] + + # Get class names and create confidence dict + class_names = classifier.label_encoder.classes_ + confidence_dict = {class_names[i]: float(prob) for i, prob in enumerate(probabilities)} + + return prediction, confidence_dict + + +def main(): + """Main prediction function.""" + parser = argparse.ArgumentParser(description="Predict hand gesture from image") + parser.add_argument( + "--model", + type=str, + default="models/rps_classifier.joblib", + help="Path to trained model file (default: models/rps_classifier.joblib)", + ) + parser.add_argument( + "--image", + type=str, + required=True, + help="Path to image file", + ) + parser.add_argument( + "--show-proba", + action="store_true", + help="Show probability scores for all classes", + ) + + args = parser.parse_args() + + # Validate paths + model_path = Path(args.model) + image_path = Path(args.image) + + if not model_path.exists(): + raise FileNotFoundError(f"Model file not found: {model_path}") + + if not image_path.exists(): + raise FileNotFoundError(f"Image file not found: {image_path}") + + # Load model + logger.info(f"Loading model from {model_path}...") + classifier = HandGestureClassifier() + classifier.load(model_path) + + # Initialize feature extractor + extractor = FeatureExtractor() + + # Predict + logger.info(f"Processing image: {image_path}") + prediction, confidence = predict_image(classifier, extractor, image_path) + + if prediction is None: + print("No hand detected in image.") + return + + # Print results + print(f"\nPrediction: {prediction}") + print(f"Confidence: {confidence[prediction]:.4f}") + + if args.show_proba: + print("\nAll class probabilities:") + for class_name, prob in sorted(confidence.items(), key=lambda x: x[1], reverse=True): + print(f" {class_name}: {prob:.4f}") + + +if __name__ == "__main__": + main() diff --git a/handmotion/train.py b/handmotion/train.py new file mode 100644 index 0000000..fef28f3 --- /dev/null +++ b/handmotion/train.py @@ -0,0 +1,200 @@ +# Copyright (C) 2026 Julia Jia +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Training script for hand gesture classifier. + +Examples: + # Default: use existing data.npz, save to models/rps_classifier.joblib + python -m handmotion.train + + # Reprocess raw data first + python -m handmotion.train --reprocess + + # Custom paths + python -m handmotion.train \\ + --data data/processed/rps/data.npz \\ + --model models/rps_classifier.joblib +""" + +import argparse +import json +import logging +from pathlib import Path + +import numpy as np +from sklearn.metrics import accuracy_score, classification_report, confusion_matrix + +from handmotion.data.processor import DataProcessor +from handmotion.model import HandGestureClassifier + +logging.basicConfig( + level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" +) +logger = logging.getLogger(__name__) + + +def evaluate_model(classifier, x_test, y_test): + """ + Evaluate model on test set. + + Args: + classifier: Trained HandGestureClassifier + x_test: Test features + y_test: Test labels (encoded) + + Returns: + Dictionary with evaluation metrics + """ + # Predict on test set + predictions_encoded = classifier.classifier.predict(x_test) + predictions = classifier.label_encoder.inverse_transform(predictions_encoded) + y_test_labels = classifier.label_encoder.inverse_transform(y_test) + + # Calculate metrics + accuracy = accuracy_score(y_test, predictions_encoded) + cm = confusion_matrix(y_test, predictions_encoded) + report = classification_report(y_test_labels, predictions, output_dict=True) + + metrics = { + "accuracy": float(accuracy), + "confusion_matrix": cm.tolist(), + "classification_report": report, + } + + return metrics + + +def print_evaluation(metrics): + """ + Print evaluation metrics to console. + + Args: + metrics: Dictionary with evaluation metrics + """ + print("\n" + "=" * 60) + print("Evaluation Results") + print("=" * 60) + print(f"\nAccuracy: {metrics['accuracy']:.4f}") + + print("\nConfusion Matrix:") + print(metrics["confusion_matrix"]) + + print("\nClassification Report:") + report = metrics["classification_report"] + for label in ["rock", "paper", "scissors"]: + if label in report: + print(f"\n{label}:") + print(f" Precision: {report[label]['precision']:.4f}") + print(f" Recall: {report[label]['recall']:.4f}") + print(f" F1-score: {report[label]['f1-score']:.4f}") + + print("\nMacro Avg:") + print(f" Precision: {report['macro avg']['precision']:.4f}") + print(f" Recall: {report['macro avg']['recall']:.4f}") + print(f" F1-score: {report['macro avg']['f1-score']:.4f}") + + print("=" * 60 + "\n") + + +def main(): + """Main training function.""" + parser = argparse.ArgumentParser(description="Train hand gesture classifier") + parser.add_argument( + "--data", + type=str, + default="data/processed/rps/data.npz", + help="Path to processed data file (default: data/processed/rps/data.npz)", + ) + parser.add_argument( + "--model", + type=str, + default="models/rps_classifier.joblib", + help="Path to save trained model (default: models/rps_classifier.joblib)", + ) + parser.add_argument( + "--metrics", + type=str, + default="models/metrics.json", + help="Path to save evaluation metrics (default: models/metrics.json)", + ) + parser.add_argument( + "--test-size", + type=float, + default=0.2, + help="Proportion of data for testing (default: 0.2)", + ) + parser.add_argument( + "--reprocess", + action="store_true", + help="Force reprocessing of raw data even if processed data exists", + ) + + args = parser.parse_args() + + # Setup paths + data_path = Path(args.data) + model_path = Path(args.model) + metrics_path = Path(args.metrics) + + # Optionally reprocess data if requested or if data doesn't exist + if args.reprocess or not data_path.exists(): + if args.reprocess: + logger.info("Reprocessing data as requested...") + else: + logger.info(f"Data file not found at {data_path}, processing raw data...") + + processor = DataProcessor() + cwd = Path.cwd() + + results = processor.process_folder(cwd / "data" / "raw" / "rps" / "rock", "rock") + results.extend(processor.process_folder(cwd / "data" / "raw" / "rps" / "paper", "paper")) + results.extend( + processor.process_folder(cwd / "data" / "raw" / "rps" / "scissors", "scissors") + ) + processor.save(results, data_path) + logger.info(f"Processed {len(results)} samples") + else: + logger.info(f"Using existing data file: {data_path}") + + # Load data + logger.info("Loading data...") + classifier = HandGestureClassifier() + features, labels = classifier.load_data(data_path) + logger.info(f"Loaded {len(features)} samples with {len(np.unique(labels))} classes") + + # Train model + logger.info("Training classifier...") + x_train, x_test, _y_train, y_test = classifier.train(features, labels, test_size=args.test_size) + logger.info(f"Training set: {len(x_train)} samples, Test set: {len(x_test)} samples") + + # Evaluate model + logger.info("Evaluating model...") + metrics = evaluate_model(classifier, x_test, y_test) + print_evaluation(metrics) + + # Save model + logger.info(f"Saving model to {model_path}...") + classifier.save(model_path) + + # Save metrics + logger.info(f"Saving metrics to {metrics_path}...") + metrics_path.parent.mkdir(parents=True, exist_ok=True) + with open(metrics_path, "w") as f: + json.dump(metrics, f, indent=2) + + logger.info("Training complete!") + + +if __name__ == "__main__": + main() diff --git a/models/README.md b/models/README.md new file mode 100644 index 0000000..1410e8e --- /dev/null +++ b/models/README.md @@ -0,0 +1,38 @@ +# Models Directory + +This directory contains model files used by the hand gesture classification system. + +## Files + +### `hand_landmarker.task` +MediaPipe hand landmark detection model used for feature extraction. + +- Purpose: Detects 21 hand landmark points in images +- Used by: `FeatureExtractor` class +- Source: MediaPipe hand landmarker model +- Download: If missing, manually download from: + https://storage.googleapis.com/mediapipe-models/hand_landmarker/hand_landmarker/float16/1/hand_landmarker.task + Or see official documentation: https://ai.google.dev/edge/mediapipe/solutions/vision/hand_landmarker/python + The system will also attempt to use MediaPipe's bundled model if available. + +### `rps_classifier.joblib` +Trained scikit-learn classifier for rock-paper-scissors gesture classification. + +- Purpose: Classifies extracted hand features into rock, paper, or scissors +- Created by: Training script (`handmotion.train`) +- Used by: Prediction script (`handmotion.predict`) +- Format: Joblib-serialized scikit-learn model + +### `metrics.json` +Evaluation metrics from model training. + +- Purpose: Stores accuracy, confusion matrix, and classification report +- Created by: Training script (`handmotion.train`) +- Format: JSON file with evaluation results + +## Model Pipeline + +1. `hand_landmarker.task` extracts hand landmarks from raw images +2. Features are processed and saved to `data/processed/rps/data.npz` +3. `rps_classifier.joblib` is trained on these features +4. Trained classifier is used for prediction on new images diff --git a/models/metrics.json b/models/metrics.json new file mode 100644 index 0000000..6241b59 --- /dev/null +++ b/models/metrics.json @@ -0,0 +1,53 @@ +{ + "accuracy": 0.9861111111111112, + "confusion_matrix": [ + [ + 25, + 0, + 0 + ], + [ + 0, + 22, + 0 + ], + [ + 1, + 0, + 24 + ] + ], + "classification_report": { + "paper": { + "precision": 0.9615384615384616, + "recall": 1.0, + "f1-score": 0.9803921568627451, + "support": 25.0 + }, + "rock": { + "precision": 1.0, + "recall": 1.0, + "f1-score": 1.0, + "support": 22.0 + }, + "scissors": { + "precision": 1.0, + "recall": 0.96, + "f1-score": 0.9795918367346939, + "support": 25.0 + }, + "accuracy": 0.9861111111111112, + "macro avg": { + "precision": 0.9871794871794872, + "recall": 0.9866666666666667, + "f1-score": 0.9866613311991465, + "support": 72.0 + }, + "weighted avg": { + "precision": 0.9866452991452993, + "recall": 0.9861111111111112, + "f1-score": 0.986105553332444, + "support": 72.0 + } + } +} diff --git a/models/rps_classifier.joblib b/models/rps_classifier.joblib new file mode 100644 index 0000000..8fb5837 Binary files /dev/null and b/models/rps_classifier.joblib differ diff --git a/pixi.lock b/pixi.lock new file mode 100644 index 0000000..358f5f1 --- /dev/null +++ b/pixi.lock @@ -0,0 +1,1874 @@ +version: 6 +environments: + default: + channels: + - url: https://conda.anaconda.org/conda-forge/ + indexes: + - https://pypi.org/simple + options: + pypi-prerelease-mode: if-necessary-or-explicit + packages: + linux-64: + - conda: https://conda.anaconda.org/conda-forge/linux-64/_libgcc_mutex-0.1-conda_forge.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-2_gnu.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/linux-64/bzip2-1.0.8-hda65f42_8.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.1.4-hbd8a1cb_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/cffi-2.0.0-py312h460c074_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/icu-78.2-h33c6efd_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/ld_impl_linux-64-2.45.1-default_hbd61a6d_100.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libexpat-2.7.3-hecca717_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libffi-3.5.2-h3435931_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-15.2.0-he0feb66_17.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-ng-15.2.0-h69a702a_17.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgomp-15.2.0-he0feb66_17.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/liblzma-5.8.2-hb03c661_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libnsl-2.0.1-hb9d3cd8_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.51.2-hf4e2dac_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-15.2.0-h934c35e_17.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libuuid-2.41.3-h5347b49_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libxcrypt-4.4.36-hd590300_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.3.1-hb9d3cd8_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.5-h2d0b736_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/openssl-3.6.1-h35e630c_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/packaging-26.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pycparser-2.22-pyh29332c3_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/python-3.12.12-hd63d673_2_cpython.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.12-8_cp312.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/readline-8.3-h853b02a_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_h366c992_103.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb78ec9c_6.conda + - pypi: https://files.pythonhosted.org/packages/18/a6/907a406bb7d359e6a63f99c313846d9eec4f7e6f7437809e03aa00fa3074/absl_py-2.4.0-py3-none-any.whl + - pypi: 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 + - pypi: https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/e8/2d/d2a548598be01649e2d46231d151a6c56d10b964d94043a335ae56ea2d92/flatbuffers-25.12.19-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/b7/37/82dbef0f6342eb01f54bca073ac1498433d6ce71e50c3c3282b655733b31/fonttools-4.61.1-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/70/90/6d240beb0f24b74371762873e9b7f499f1e02166a2d9c5801f4dbf8fa12e/kiwisolver-1.4.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/3e/f3/c5195b1ae57ef85339fd7285dfb603b22c8b4e79114bae5f4f0fcf688677/matplotlib-3.10.8-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/e3/98/00cd8b2dcb563f2298655633e6611a791b2c1a7df1dae064b2b96084f1bf/mediapipe-0.10.32-py3-none-manylinux_2_28_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/f5/c6/a18e59f3f0b8071cc85cbc8d80cd02d68aa9710170b2553a117203d46936/numpy-2.4.2-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/a9/3d/00071f3a395611a13efca22e3ee65aab25b8bf54128ae5080d8361cbb673/opencv_contrib_python-4.13.0.90-cp37-abi3-manylinux_2_28_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/11/8f/48d0b77ab2200374c66d344459b8958c86693be99526450e7aee714e03e4/pillow-12.1.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/97/74/b7a304feb2b49df9fafa9382d4d09061a96ee9a9449a7cbea7988dda0828/scikit_learn-1.8.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/d1/fe/66d73b76d378ba8cc2fe605920c0c75092e3a65ae746e1e767d9d020a75a/scipy-1.17.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/1e/0a/478e441fd049002cf308520c0d62dd8333e7c6cc8d997f0dda07b9fbcc46/sounddevice-0.5.5-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/32/d5/f9a850d79b0851d1d4ef6456097579a9005b31fea68726a4ae5f2d82ddd9/threadpoolctl-3.6.0-py3-none-any.whl + - pypi: ./ + linux-aarch64: + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/_openmp_mutex-4.5-2_gnu.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/bzip2-1.0.8-h4777abc_8.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.1.4-hbd8a1cb_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/cffi-2.0.0-py312h1b372e3_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/icu-78.2-hb1525cb_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/ld_impl_linux-aarch64-2.45.1-default_h1979696_100.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libexpat-2.7.3-hfae3067_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libffi-3.5.2-h376a255_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libgcc-15.2.0-h8acb6b2_17.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libgcc-ng-15.2.0-he9431aa_17.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libgomp-15.2.0-h8acb6b2_17.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/liblzma-5.8.2-he30d5cf_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libnsl-2.0.1-h86ecc28_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libsqlite-3.51.2-h10b116e_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libstdcxx-15.2.0-hef695bb_17.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libuuid-2.41.3-h1022ec0_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libxcrypt-4.4.36-h31becfc_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libzlib-1.3.1-h86ecc28_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/ncurses-6.5-ha32ae93_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/openssl-3.6.1-h546c87b_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/packaging-26.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pycparser-2.22-pyh29332c3_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/python-3.12.12-h91f4b29_2_cpython.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.12-8_cp312.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/readline-8.3-hb682ff5_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/tk-8.6.13-noxft_h0dc03b3_103.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/zstd-1.5.7-h85ac4a6_6.conda + - pypi: https://files.pythonhosted.org/packages/18/a6/907a406bb7d359e6a63f99c313846d9eec4f7e6f7437809e03aa00fa3074/absl_py-2.4.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/d4/1c/a12359b9b2ca3a845e8f7f9ac08bdf776114eb931392fcad91743e2ea17b/contourpy-1.3.3-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl + - pypi: https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/e8/2d/d2a548598be01649e2d46231d151a6c56d10b964d94043a335ae56ea2d92/flatbuffers-25.12.19-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/6c/44/f3aeac0fa98e7ad527f479e161aca6c3a1e47bb6996b053d45226fe37bf2/fonttools-4.61.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl + - pypi: https://files.pythonhosted.org/packages/83/81/793d78c91b0546b3b1f08e55fdd97437174171cd7d70e46098f1a4d94b7b/jax-0.7.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/de/4d/76ee71959311fe3da9951aa6f55af8f98eb3572bb322f5a7c89faf7ab933/jaxlib-0.7.1-cp312-cp312-manylinux2014_aarch64.whl + - pypi: https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/12/42/f36816eaf465220f683fb711efdd1bbf7a7005a2473d0e4ed421389bd26c/kiwisolver-1.4.9-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl + - pypi: https://files.pythonhosted.org/packages/00/f9/7638f5cc82ec8a7aa005de48622eecc3ed7c9854b96ba15bd76b7fd27574/matplotlib-3.10.8-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl + - pypi: https://files.pythonhosted.org/packages/a8/f2/c8f62565abc93b9ac6a9936856d3c3c144c7f7896ef3d02bfbfad2ab6ee7/mediapipe-0.10.18-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl + - pypi: https://files.pythonhosted.org/packages/54/0f/428ef6881782e5ebb7eca459689448c0394fa0a80bea3aa9262cba5445ea/ml_dtypes-0.5.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl + - pypi: https://files.pythonhosted.org/packages/79/f8/97f10e6755e2a7d027ca783f63044d5b1bc1ae7acb12afe6a9b4286eac17/numpy-1.26.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl + - pypi: https://files.pythonhosted.org/packages/ae/7b/7e1471aa92f9f3c1bd8dbe624622b62add6f734db34fbbb9974e2ec70c34/opencv_contrib_python-4.11.0.86-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl + - pypi: https://files.pythonhosted.org/packages/23/cd/066e86230ae37ed0be70aae89aabf03ca8d9f39c8aea0dec8029455b5540/opt_einsum-3.4.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/0a/ea/f200a4c36d836100e7bc738fc48cd963d3ba6372ebc8298a889e0cfc3359/pillow-12.1.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl + - pypi: https://files.pythonhosted.org/packages/bd/6d/a4a198b61808dd3d1ee187082ccc21499bc949d639feb948961b48be9a7e/protobuf-4.25.8-cp37-abi3-manylinux2014_aarch64.whl + - pypi: https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/dd/47/f187b4636ff80cc63f21cd40b7b2d177134acaa10f6bb73746130ee8c2e5/scikit_learn-1.8.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl + - pypi: https://files.pythonhosted.org/packages/4a/69/7c347e857224fcaf32a34a05183b9d8a7aca25f8f2d10b8a698b8388561a/scipy-1.17.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl + - pypi: https://files.pythonhosted.org/packages/89/fa/d3d5ebcba3cb9e6d3775a096251860c41a6bc53a1b9461151df83fe93255/sentencepiece-0.2.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl + - pypi: https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/1e/0a/478e441fd049002cf308520c0d62dd8333e7c6cc8d997f0dda07b9fbcc46/sounddevice-0.5.5-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/32/d5/f9a850d79b0851d1d4ef6456097579a9005b31fea68726a4ae5f2d82ddd9/threadpoolctl-3.6.0-py3-none-any.whl + - pypi: ./ + dev: + channels: + - url: https://conda.anaconda.org/conda-forge/ + indexes: + - https://pypi.org/simple + options: + pypi-prerelease-mode: if-necessary-or-explicit + packages: + linux-64: + - conda: https://conda.anaconda.org/conda-forge/linux-64/_libgcc_mutex-0.1-conda_forge.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-2_gnu.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/linux-64/bzip2-1.0.8-hda65f42_8.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.1.4-hbd8a1cb_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/cffi-2.0.0-py312h460c074_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/cfgv-3.5.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/distlib-0.4.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/exceptiongroup-1.3.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/filelock-3.20.3-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/icu-78.2-h33c6efd_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/identify-2.6.16-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/iniconfig-2.3.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/ld_impl_linux-64-2.45.1-default_hbd61a6d_100.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libexpat-2.7.3-hecca717_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libffi-3.5.2-h3435931_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-15.2.0-he0feb66_17.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-ng-15.2.0-h69a702a_17.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgomp-15.2.0-he0feb66_17.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/liblzma-5.8.2-hb03c661_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libnsl-2.0.1-hb9d3cd8_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.51.2-hf4e2dac_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-15.2.0-h934c35e_17.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libuuid-2.41.3-h5347b49_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libxcrypt-4.4.36-hd590300_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.3.1-hb9d3cd8_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.5-h2d0b736_3.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/nodeenv-1.10.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/openssl-3.6.1-h35e630c_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/packaging-26.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.5.1-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pluggy-1.6.0-pyhf9edf01_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pre-commit-4.5.1-pyha770c72_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pycparser-2.22-pyh29332c3_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pygments-2.19.2-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pytest-9.0.2-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pytest-timeout-2.4.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/python-3.12.12-hd63d673_2_cpython.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.12-8_cp312.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/pyyaml-6.0.3-py312h8a5da7c_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/readline-8.3-h853b02a_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/ruff-0.15.0-h40fa522_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/setuptools-80.10.2-pyh332efcf_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_h366c992_103.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/tomli-2.4.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.15.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/ukkonen-1.1.0-py312hd9148b4_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/virtualenv-20.36.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/yaml-0.2.5-h280c20c_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb78ec9c_6.conda + - pypi: https://files.pythonhosted.org/packages/18/a6/907a406bb7d359e6a63f99c313846d9eec4f7e6f7437809e03aa00fa3074/absl_py-2.4.0-py3-none-any.whl + - pypi: 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 + - pypi: https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/e8/2d/d2a548598be01649e2d46231d151a6c56d10b964d94043a335ae56ea2d92/flatbuffers-25.12.19-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/b7/37/82dbef0f6342eb01f54bca073ac1498433d6ce71e50c3c3282b655733b31/fonttools-4.61.1-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/70/90/6d240beb0f24b74371762873e9b7f499f1e02166a2d9c5801f4dbf8fa12e/kiwisolver-1.4.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/3e/f3/c5195b1ae57ef85339fd7285dfb603b22c8b4e79114bae5f4f0fcf688677/matplotlib-3.10.8-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/e3/98/00cd8b2dcb563f2298655633e6611a791b2c1a7df1dae064b2b96084f1bf/mediapipe-0.10.32-py3-none-manylinux_2_28_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/f5/c6/a18e59f3f0b8071cc85cbc8d80cd02d68aa9710170b2553a117203d46936/numpy-2.4.2-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/a9/3d/00071f3a395611a13efca22e3ee65aab25b8bf54128ae5080d8361cbb673/opencv_contrib_python-4.13.0.90-cp37-abi3-manylinux_2_28_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/11/8f/48d0b77ab2200374c66d344459b8958c86693be99526450e7aee714e03e4/pillow-12.1.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/97/74/b7a304feb2b49df9fafa9382d4d09061a96ee9a9449a7cbea7988dda0828/scikit_learn-1.8.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/d1/fe/66d73b76d378ba8cc2fe605920c0c75092e3a65ae746e1e767d9d020a75a/scipy-1.17.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/1e/0a/478e441fd049002cf308520c0d62dd8333e7c6cc8d997f0dda07b9fbcc46/sounddevice-0.5.5-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/32/d5/f9a850d79b0851d1d4ef6456097579a9005b31fea68726a4ae5f2d82ddd9/threadpoolctl-3.6.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/13/41/5bf882649bd8b64ded5fbce7fb8d77fb3b868de1a3b1a6c4796402b47308/ty-0.0.15-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl + - pypi: ./ + linux-aarch64: + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/_openmp_mutex-4.5-2_gnu.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/bzip2-1.0.8-h4777abc_8.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.1.4-hbd8a1cb_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/cffi-2.0.0-py312h1b372e3_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/cfgv-3.5.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/distlib-0.4.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/exceptiongroup-1.3.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/filelock-3.20.3-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/icu-78.2-hb1525cb_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/identify-2.6.16-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/iniconfig-2.3.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/ld_impl_linux-aarch64-2.45.1-default_h1979696_100.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libexpat-2.7.3-hfae3067_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libffi-3.5.2-h376a255_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libgcc-15.2.0-h8acb6b2_17.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libgcc-ng-15.2.0-he9431aa_17.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libgomp-15.2.0-h8acb6b2_17.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/liblzma-5.8.2-he30d5cf_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libnsl-2.0.1-h86ecc28_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libsqlite-3.51.2-h10b116e_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libstdcxx-15.2.0-hef695bb_17.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libuuid-2.41.3-h1022ec0_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libxcrypt-4.4.36-h31becfc_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libzlib-1.3.1-h86ecc28_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/ncurses-6.5-ha32ae93_3.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/nodeenv-1.10.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/openssl-3.6.1-h546c87b_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/packaging-26.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.5.1-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pluggy-1.6.0-pyhf9edf01_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pre-commit-4.5.1-pyha770c72_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pycparser-2.22-pyh29332c3_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pygments-2.19.2-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pytest-9.0.2-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pytest-timeout-2.4.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/python-3.12.12-h91f4b29_2_cpython.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.12-8_cp312.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/pyyaml-6.0.3-py312ha4530ae_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/readline-8.3-hb682ff5_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/ruff-0.15.0-he9a2e21_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/setuptools-80.10.2-pyh332efcf_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/tk-8.6.13-noxft_h0dc03b3_103.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/tomli-2.4.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.15.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/ukkonen-1.1.0-py312h4f740d2_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/virtualenv-20.36.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/yaml-0.2.5-h80f16a2_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/zstd-1.5.7-h85ac4a6_6.conda + - pypi: https://files.pythonhosted.org/packages/18/a6/907a406bb7d359e6a63f99c313846d9eec4f7e6f7437809e03aa00fa3074/absl_py-2.4.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/d4/1c/a12359b9b2ca3a845e8f7f9ac08bdf776114eb931392fcad91743e2ea17b/contourpy-1.3.3-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl + - pypi: https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/e8/2d/d2a548598be01649e2d46231d151a6c56d10b964d94043a335ae56ea2d92/flatbuffers-25.12.19-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/6c/44/f3aeac0fa98e7ad527f479e161aca6c3a1e47bb6996b053d45226fe37bf2/fonttools-4.61.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl + - pypi: https://files.pythonhosted.org/packages/83/81/793d78c91b0546b3b1f08e55fdd97437174171cd7d70e46098f1a4d94b7b/jax-0.7.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/de/4d/76ee71959311fe3da9951aa6f55af8f98eb3572bb322f5a7c89faf7ab933/jaxlib-0.7.1-cp312-cp312-manylinux2014_aarch64.whl + - pypi: https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/12/42/f36816eaf465220f683fb711efdd1bbf7a7005a2473d0e4ed421389bd26c/kiwisolver-1.4.9-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl + - pypi: https://files.pythonhosted.org/packages/00/f9/7638f5cc82ec8a7aa005de48622eecc3ed7c9854b96ba15bd76b7fd27574/matplotlib-3.10.8-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl + - pypi: https://files.pythonhosted.org/packages/a8/f2/c8f62565abc93b9ac6a9936856d3c3c144c7f7896ef3d02bfbfad2ab6ee7/mediapipe-0.10.18-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl + - pypi: https://files.pythonhosted.org/packages/54/0f/428ef6881782e5ebb7eca459689448c0394fa0a80bea3aa9262cba5445ea/ml_dtypes-0.5.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl + - pypi: https://files.pythonhosted.org/packages/79/f8/97f10e6755e2a7d027ca783f63044d5b1bc1ae7acb12afe6a9b4286eac17/numpy-1.26.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl + - pypi: https://files.pythonhosted.org/packages/ae/7b/7e1471aa92f9f3c1bd8dbe624622b62add6f734db34fbbb9974e2ec70c34/opencv_contrib_python-4.11.0.86-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl + - pypi: https://files.pythonhosted.org/packages/23/cd/066e86230ae37ed0be70aae89aabf03ca8d9f39c8aea0dec8029455b5540/opt_einsum-3.4.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/0a/ea/f200a4c36d836100e7bc738fc48cd963d3ba6372ebc8298a889e0cfc3359/pillow-12.1.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl + - pypi: https://files.pythonhosted.org/packages/bd/6d/a4a198b61808dd3d1ee187082ccc21499bc949d639feb948961b48be9a7e/protobuf-4.25.8-cp37-abi3-manylinux2014_aarch64.whl + - pypi: https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/dd/47/f187b4636ff80cc63f21cd40b7b2d177134acaa10f6bb73746130ee8c2e5/scikit_learn-1.8.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl + - pypi: https://files.pythonhosted.org/packages/4a/69/7c347e857224fcaf32a34a05183b9d8a7aca25f8f2d10b8a698b8388561a/scipy-1.17.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl + - pypi: https://files.pythonhosted.org/packages/89/fa/d3d5ebcba3cb9e6d3775a096251860c41a6bc53a1b9461151df83fe93255/sentencepiece-0.2.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl + - pypi: https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/1e/0a/478e441fd049002cf308520c0d62dd8333e7c6cc8d997f0dda07b9fbcc46/sounddevice-0.5.5-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/32/d5/f9a850d79b0851d1d4ef6456097579a9005b31fea68726a4ae5f2d82ddd9/threadpoolctl-3.6.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/3c/d9/244bc02599d950f7a4298fbc0c1b25cc808646b9577bdf7a83470b2d1cec/ty-0.0.15-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl + - pypi: ./ +packages: +- conda: https://conda.anaconda.org/conda-forge/linux-64/_libgcc_mutex-0.1-conda_forge.tar.bz2 + sha256: fe51de6107f9edc7aa4f786a70f4a883943bc9d39b3bb7307c04c41410990726 + md5: d7c89558ba9fa0495403155b64376d81 + license: None + purls: [] + size: 2562 + timestamp: 1578324546067 +- conda: https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-2_gnu.tar.bz2 + build_number: 16 + sha256: fbe2c5e56a653bebb982eda4876a9178aedfc2b545f25d0ce9c4c0b508253d22 + md5: 73aaf86a425cc6e73fcf236a5a46396d + depends: + - _libgcc_mutex 0.1 conda_forge + - libgomp >=7.5.0 + constrains: + - openmp_impl 9999 + license: BSD-3-Clause + license_family: BSD + purls: [] + size: 23621 + timestamp: 1650670423406 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/_openmp_mutex-4.5-2_gnu.tar.bz2 + build_number: 16 + sha256: 3702bef2f0a4d38bd8288bbe54aace623602a1343c2cfbefd3fa188e015bebf0 + md5: 6168d71addc746e8f2b8d57dfd2edcea + depends: + - libgomp >=7.5.0 + constrains: + - openmp_impl 9999 + license: BSD-3-Clause + license_family: BSD + purls: [] + size: 23712 + timestamp: 1650670790230 +- pypi: https://files.pythonhosted.org/packages/18/a6/907a406bb7d359e6a63f99c313846d9eec4f7e6f7437809e03aa00fa3074/absl_py-2.4.0-py3-none-any.whl + name: absl-py + version: 2.4.0 + sha256: 88476fd881ca8aab94ffa78b7b6c632a782ab3ba1cd19c9bd423abc4fb4cd28d + requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl + name: attrs + version: 25.4.0 + sha256: adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373 + requires_python: '>=3.9' +- conda: https://conda.anaconda.org/conda-forge/linux-64/bzip2-1.0.8-hda65f42_8.conda + sha256: c30daba32ddebbb7ded490f0e371eae90f51e72db620554089103b4a6934b0d5 + md5: 51a19bba1b8ebfb60df25cde030b7ebc + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + license: bzip2-1.0.6 + license_family: BSD + purls: [] + size: 260341 + timestamp: 1757437258798 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/bzip2-1.0.8-h4777abc_8.conda + sha256: d2a296aa0b5f38ed9c264def6cf775c0ccb0f110ae156fcde322f3eccebf2e01 + md5: 2921ac0b541bf37c69e66bd6d9a43bca + depends: + - libgcc >=14 + license: bzip2-1.0.6 + license_family: BSD + purls: [] + size: 192536 + timestamp: 1757437302703 +- conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.1.4-hbd8a1cb_0.conda + sha256: b5974ec9b50e3c514a382335efa81ed02b05906849827a34061c496f4defa0b2 + md5: bddacf101bb4dd0e51811cb69c7790e2 + depends: + - __unix + license: ISC + purls: [] + size: 146519 + timestamp: 1767500828366 +- conda: https://conda.anaconda.org/conda-forge/linux-64/cffi-2.0.0-py312h460c074_1.conda + sha256: 7dafe8173d5f94e46cf9cd597cc8ff476a8357fbbd4433a8b5697b2864845d9c + md5: 648ee28dcd4e07a1940a17da62eccd40 + depends: + - __glibc >=2.17,<3.0.a0 + - libffi >=3.5.2,<3.6.0a0 + - libgcc >=14 + - pycparser + - python >=3.12,<3.13.0a0 + - python_abi 3.12.* *_cp312 + license: MIT + license_family: MIT + purls: + - pkg:pypi/cffi?source=hash-mapping + size: 295716 + timestamp: 1761202958833 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/cffi-2.0.0-py312h1b372e3_1.conda + sha256: 6028633bdb037c14bab99022a6ee40b6abd5a921b2c1023d7655f98eb5edf233 + md5: 1e7bf495417ed1c23ccd6ec1075b8403 + depends: + - libffi >=3.5.2,<3.6.0a0 + - libgcc >=14 + - pycparser + - python >=3.12,<3.13.0a0 + - python_abi 3.12.* *_cp312 + license: MIT + license_family: MIT + purls: + - pkg:pypi/cffi?source=hash-mapping + size: 315113 + timestamp: 1761203960926 +- conda: https://conda.anaconda.org/conda-forge/noarch/cfgv-3.5.0-pyhd8ed1ab_0.conda + sha256: aa589352e61bb221351a79e5946d56916e3c595783994884accdb3b97fe9d449 + md5: 381bd45fb7aa032691f3063aff47e3a1 + depends: + - python >=3.10 + license: MIT + license_family: MIT + purls: + - pkg:pypi/cfgv?source=hash-mapping + size: 13589 + timestamp: 1763607964133 +- conda: https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda + sha256: ab29d57dc70786c1269633ba3dff20288b81664d3ff8d21af995742e2bb03287 + md5: 962b9857ee8e7018c22f2776ffa0b2d7 + depends: + - python >=3.9 + license: BSD-3-Clause + license_family: BSD + purls: + - pkg:pypi/colorama?source=hash-mapping + size: 27011 + timestamp: 1733218222191 +- pypi: 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 + name: contourpy + version: 1.3.3 + sha256: 4d00e655fcef08aba35ec9610536bfe90267d7ab5ba944f7032549c55a146da1 + requires_dist: + - numpy>=1.25 + - furo ; extra == 'docs' + - sphinx>=7.2 ; extra == 'docs' + - sphinx-copybutton ; extra == 'docs' + - bokeh ; extra == 'bokeh' + - selenium ; extra == 'bokeh' + - contourpy[bokeh,docs] ; extra == 'mypy' + - bokeh ; extra == 'mypy' + - docutils-stubs ; extra == 'mypy' + - mypy==1.17.0 ; extra == 'mypy' + - types-pillow ; extra == 'mypy' + - contourpy[test-no-images] ; extra == 'test' + - matplotlib ; extra == 'test' + - pillow ; extra == 'test' + - pytest ; extra == 'test-no-images' + - pytest-cov ; extra == 'test-no-images' + - pytest-rerunfailures ; extra == 'test-no-images' + - pytest-xdist ; extra == 'test-no-images' + - wurlitzer ; extra == 'test-no-images' + requires_python: '>=3.11' +- pypi: https://files.pythonhosted.org/packages/d4/1c/a12359b9b2ca3a845e8f7f9ac08bdf776114eb931392fcad91743e2ea17b/contourpy-1.3.3-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl + name: contourpy + version: 1.3.3 + sha256: 92d9abc807cf7d0e047b95ca5d957cf4792fcd04e920ca70d48add15c1a90ea7 + requires_dist: + - numpy>=1.25 + - furo ; extra == 'docs' + - sphinx>=7.2 ; extra == 'docs' + - sphinx-copybutton ; extra == 'docs' + - bokeh ; extra == 'bokeh' + - selenium ; extra == 'bokeh' + - contourpy[bokeh,docs] ; extra == 'mypy' + - bokeh ; extra == 'mypy' + - docutils-stubs ; extra == 'mypy' + - mypy==1.17.0 ; extra == 'mypy' + - types-pillow ; extra == 'mypy' + - contourpy[test-no-images] ; extra == 'test' + - matplotlib ; extra == 'test' + - pillow ; extra == 'test' + - pytest ; extra == 'test-no-images' + - pytest-cov ; extra == 'test-no-images' + - pytest-rerunfailures ; extra == 'test-no-images' + - pytest-xdist ; extra == 'test-no-images' + - wurlitzer ; extra == 'test-no-images' + requires_python: '>=3.11' +- pypi: https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl + name: cycler + version: 0.12.1 + sha256: 85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30 + requires_dist: + - ipython ; extra == 'docs' + - matplotlib ; extra == 'docs' + - numpydoc ; extra == 'docs' + - sphinx ; extra == 'docs' + - pytest ; extra == 'tests' + - pytest-cov ; extra == 'tests' + - pytest-xdist ; extra == 'tests' + requires_python: '>=3.8' +- conda: https://conda.anaconda.org/conda-forge/noarch/distlib-0.4.0-pyhd8ed1ab_0.conda + sha256: 6d977f0b2fc24fee21a9554389ab83070db341af6d6f09285360b2e09ef8b26e + md5: 003b8ba0a94e2f1e117d0bd46aebc901 + depends: + - python >=3.9 + license: Apache-2.0 + license_family: APACHE + purls: + - pkg:pypi/distlib?source=hash-mapping + size: 275642 + timestamp: 1752823081585 +- conda: https://conda.anaconda.org/conda-forge/noarch/exceptiongroup-1.3.1-pyhd8ed1ab_0.conda + sha256: ee6cf346d017d954255bbcbdb424cddea4d14e4ed7e9813e429db1d795d01144 + md5: 8e662bd460bda79b1ea39194e3c4c9ab + depends: + - python >=3.10 + - typing_extensions >=4.6.0 + license: MIT and PSF-2.0 + purls: + - pkg:pypi/exceptiongroup?source=hash-mapping + size: 21333 + timestamp: 1763918099466 +- conda: https://conda.anaconda.org/conda-forge/noarch/filelock-3.20.3-pyhd8ed1ab_0.conda + sha256: 8b90dc21f00167a7e58abb5141a140bdb31a7c5734fe1361b5f98f4a4183fd32 + md5: 2cfaaccf085c133a477f0a7a8657afe9 + depends: + - python >=3.10 + license: Unlicense + purls: + - pkg:pypi/filelock?source=hash-mapping + size: 18661 + timestamp: 1768022315929 +- pypi: https://files.pythonhosted.org/packages/e8/2d/d2a548598be01649e2d46231d151a6c56d10b964d94043a335ae56ea2d92/flatbuffers-25.12.19-py2.py3-none-any.whl + name: flatbuffers + version: 25.12.19 + sha256: 7634f50c427838bb021c2d66a3d1168e9d199b0607e6329399f04846d42e20b4 +- pypi: https://files.pythonhosted.org/packages/6c/44/f3aeac0fa98e7ad527f479e161aca6c3a1e47bb6996b053d45226fe37bf2/fonttools-4.61.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl + name: fonttools + version: 4.61.1 + sha256: 15acc09befd16a0fb8a8f62bc147e1a82817542d72184acca9ce6e0aeda9fa6d + requires_dist: + - lxml>=4.0 ; extra == 'lxml' + - brotli>=1.0.1 ; platform_python_implementation == 'CPython' and extra == 'woff' + - brotlicffi>=0.8.0 ; platform_python_implementation != 'CPython' and extra == 'woff' + - zopfli>=0.1.4 ; extra == 'woff' + - unicodedata2>=17.0.0 ; python_full_version < '3.15' and extra == 'unicode' + - lz4>=1.7.4.2 ; extra == 'graphite' + - scipy ; platform_python_implementation != 'PyPy' and extra == 'interpolatable' + - munkres ; platform_python_implementation == 'PyPy' and extra == 'interpolatable' + - pycairo ; extra == 'interpolatable' + - matplotlib ; extra == 'plot' + - sympy ; extra == 'symfont' + - xattr ; sys_platform == 'darwin' and extra == 'type1' + - skia-pathops>=0.5.0 ; extra == 'pathops' + - uharfbuzz>=0.45.0 ; extra == 'repacker' + - lxml>=4.0 ; extra == 'all' + - brotli>=1.0.1 ; platform_python_implementation == 'CPython' and extra == 'all' + - brotlicffi>=0.8.0 ; platform_python_implementation != 'CPython' and extra == 'all' + - zopfli>=0.1.4 ; extra == 'all' + - unicodedata2>=17.0.0 ; python_full_version < '3.15' and extra == 'all' + - lz4>=1.7.4.2 ; extra == 'all' + - scipy ; platform_python_implementation != 'PyPy' and extra == 'all' + - munkres ; platform_python_implementation == 'PyPy' and extra == 'all' + - pycairo ; extra == 'all' + - matplotlib ; extra == 'all' + - sympy ; extra == 'all' + - xattr ; sys_platform == 'darwin' and extra == 'all' + - skia-pathops>=0.5.0 ; extra == 'all' + - uharfbuzz>=0.45.0 ; extra == 'all' + requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/b7/37/82dbef0f6342eb01f54bca073ac1498433d6ce71e50c3c3282b655733b31/fonttools-4.61.1-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl + name: fonttools + version: 4.61.1 + sha256: 10d88e55330e092940584774ee5e8a6971b01fc2f4d3466a1d6c158230880796 + requires_dist: + - lxml>=4.0 ; extra == 'lxml' + - brotli>=1.0.1 ; platform_python_implementation == 'CPython' and extra == 'woff' + - brotlicffi>=0.8.0 ; platform_python_implementation != 'CPython' and extra == 'woff' + - zopfli>=0.1.4 ; extra == 'woff' + - unicodedata2>=17.0.0 ; python_full_version < '3.15' and extra == 'unicode' + - lz4>=1.7.4.2 ; extra == 'graphite' + - scipy ; platform_python_implementation != 'PyPy' and extra == 'interpolatable' + - munkres ; platform_python_implementation == 'PyPy' and extra == 'interpolatable' + - pycairo ; extra == 'interpolatable' + - matplotlib ; extra == 'plot' + - sympy ; extra == 'symfont' + - xattr ; sys_platform == 'darwin' and extra == 'type1' + - skia-pathops>=0.5.0 ; extra == 'pathops' + - uharfbuzz>=0.45.0 ; extra == 'repacker' + - lxml>=4.0 ; extra == 'all' + - brotli>=1.0.1 ; platform_python_implementation == 'CPython' and extra == 'all' + - brotlicffi>=0.8.0 ; platform_python_implementation != 'CPython' and extra == 'all' + - zopfli>=0.1.4 ; extra == 'all' + - unicodedata2>=17.0.0 ; python_full_version < '3.15' and extra == 'all' + - lz4>=1.7.4.2 ; extra == 'all' + - scipy ; platform_python_implementation != 'PyPy' and extra == 'all' + - munkres ; platform_python_implementation == 'PyPy' and extra == 'all' + - pycairo ; extra == 'all' + - matplotlib ; extra == 'all' + - sympy ; extra == 'all' + - xattr ; sys_platform == 'darwin' and extra == 'all' + - skia-pathops>=0.5.0 ; extra == 'all' + - uharfbuzz>=0.45.0 ; extra == 'all' + requires_python: '>=3.10' +- pypi: ./ + name: handmotion + version: 0.0.1 + sha256: a30a866ccc57156cde26631dbf64d63a4cb48c8cf26df1a698263ddf54a45240 + requires_python: '>=3.12,<3.13' +- conda: https://conda.anaconda.org/conda-forge/linux-64/icu-78.2-h33c6efd_0.conda + sha256: 142a722072fa96cf16ff98eaaf641f54ab84744af81754c292cb81e0881c0329 + md5: 186a18e3ba246eccfc7cff00cd19a870 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libstdcxx >=14 + license: MIT + license_family: MIT + purls: [] + size: 12728445 + timestamp: 1767969922681 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/icu-78.2-hb1525cb_0.conda + sha256: 09f7f9213eb68e7e4291cd476e72b37f3ded99ed957528567f32f5ba6b611043 + md5: 15b35dc33e185e7d2aac1cfcd6778627 + depends: + - libgcc >=14 + - libstdcxx >=14 + license: MIT + license_family: MIT + purls: [] + size: 12852963 + timestamp: 1767975394622 +- conda: https://conda.anaconda.org/conda-forge/noarch/identify-2.6.16-pyhd8ed1ab_0.conda + sha256: 6a88cdde151469131df1948839ac2315ada99cf8d38aaacc9a7a5984e9cd8c19 + md5: 8bc5851c415865334882157127e75799 + depends: + - python >=3.10 + - ukkonen + license: MIT + license_family: MIT + purls: + - pkg:pypi/identify?source=compressed-mapping + size: 79302 + timestamp: 1768295306539 +- conda: https://conda.anaconda.org/conda-forge/noarch/iniconfig-2.3.0-pyhd8ed1ab_0.conda + sha256: e1a9e3b1c8fe62dc3932a616c284b5d8cbe3124bbfbedcf4ce5c828cb166ee19 + md5: 9614359868482abba1bd15ce465e3c42 + depends: + - python >=3.10 + license: MIT + license_family: MIT + purls: + - pkg:pypi/iniconfig?source=compressed-mapping + size: 13387 + timestamp: 1760831448842 +- pypi: https://files.pythonhosted.org/packages/83/81/793d78c91b0546b3b1f08e55fdd97437174171cd7d70e46098f1a4d94b7b/jax-0.7.1-py3-none-any.whl + name: jax + version: 0.7.1 + sha256: 056e576e0e58465506125699f48111ac8891cce4c9ebf034704c42b219dfd4a6 + requires_dist: + - jaxlib<=0.7.1,>=0.7.1 + - ml-dtypes>=0.5.0 + - numpy>=1.26 + - opt-einsum + - scipy>=1.12 + - jaxlib==0.7.1 ; extra == 'minimum-jaxlib' + - jaxlib==0.7.0 ; extra == 'ci' + - jaxlib<=0.7.1,>=0.7.1 ; extra == 'tpu' + - libtpu==0.0.20.* ; extra == 'tpu' + - requests ; extra == 'tpu' + - jaxlib<=0.7.1,>=0.7.1 ; extra == 'cuda' + - jax-cuda12-plugin[with-cuda]<=0.7.1,>=0.7.1 ; extra == 'cuda' + - jaxlib<=0.7.1,>=0.7.1 ; extra == 'cuda12' + - jax-cuda12-plugin[with-cuda]<=0.7.1,>=0.7.1 ; extra == 'cuda12' + - jaxlib<=0.7.1,>=0.7.1 ; extra == 'cuda13' + - jax-cuda13-plugin[with-cuda]<=0.7.1,>=0.7.1 ; extra == 'cuda13' + - jaxlib<=0.7.1,>=0.7.1 ; extra == 'cuda12-local' + - jax-cuda12-plugin<=0.7.1,>=0.7.1 ; extra == 'cuda12-local' + - jaxlib<=0.7.1,>=0.7.1 ; extra == 'cuda13-local' + - jax-cuda13-plugin<=0.7.1,>=0.7.1 ; extra == 'cuda13-local' + - jaxlib<=0.7.1,>=0.7.1 ; extra == 'rocm' + - jax-rocm60-plugin<=0.7.1,>=0.7.1 ; extra == 'rocm' + - kubernetes ; extra == 'k8s' + - xprof ; extra == 'xprof' + requires_python: '>=3.11' +- pypi: https://files.pythonhosted.org/packages/de/4d/76ee71959311fe3da9951aa6f55af8f98eb3572bb322f5a7c89faf7ab933/jaxlib-0.7.1-cp312-cp312-manylinux2014_aarch64.whl + name: jaxlib + version: 0.7.1 + sha256: f0f1f52956b8c2518ab000a4d3d8c21be777e1d47f926ba03640e391061a41ee + requires_dist: + - scipy>=1.12 + - numpy>=1.26 + - ml-dtypes>=0.5.0 + requires_python: '>=3.11' +- pypi: https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl + name: joblib + version: 1.5.3 + sha256: 5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713 + requires_python: '>=3.9' +- pypi: https://files.pythonhosted.org/packages/12/42/f36816eaf465220f683fb711efdd1bbf7a7005a2473d0e4ed421389bd26c/kiwisolver-1.4.9-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl + name: kiwisolver + version: 1.4.9 + sha256: 67bb8b474b4181770f926f7b7d2f8c0248cbcb78b660fdd41a47054b28d2a752 + requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/70/90/6d240beb0f24b74371762873e9b7f499f1e02166a2d9c5801f4dbf8fa12e/kiwisolver-1.4.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl + name: kiwisolver + version: 1.4.9 + sha256: f6008a4919fdbc0b0097089f67a1eb55d950ed7e90ce2cc3e640abadd2757a04 + requires_python: '>=3.10' +- conda: https://conda.anaconda.org/conda-forge/linux-64/ld_impl_linux-64-2.45.1-default_hbd61a6d_100.conda + sha256: bc1bcc4f805216d40cab68bcb1edfbe0bfa2e00f08651a922b3abe51afb8d976 + md5: 14c30c9ca88f15886f4b594911574be2 + depends: + - __glibc >=2.17,<3.0.a0 + - zstd >=1.5.7,<1.6.0a0 + constrains: + - binutils_impl_linux-64 2.45.1 + license: GPL-3.0-only + purls: [] + size: 725290 + timestamp: 1770249516783 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/ld_impl_linux-aarch64-2.45.1-default_h1979696_100.conda + sha256: 3c9a062440d08fba0e5f33577e3ec2bc6ee3aed19f6013bba1a1869e88a107a5 + md5: d248df3c03638cc71b7ac4248b27734c + depends: + - zstd >=1.5.7,<1.6.0a0 + constrains: + - binutils_impl_linux-aarch64 2.45.1 + license: GPL-3.0-only + purls: [] + size: 876530 + timestamp: 1770249595622 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libexpat-2.7.3-hecca717_0.conda + sha256: 1e1b08f6211629cbc2efe7a5bca5953f8f6b3cae0eeb04ca4dacee1bd4e2db2f + md5: 8b09ae86839581147ef2e5c5e229d164 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + constrains: + - expat 2.7.3.* + license: MIT + license_family: MIT + purls: [] + size: 76643 + timestamp: 1763549731408 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libexpat-2.7.3-hfae3067_0.conda + sha256: cc2581a78315418cc2e0bb2a273d37363203e79cefe78ba6d282fed546262239 + md5: b414e36fbb7ca122030276c75fa9c34a + depends: + - libgcc >=14 + constrains: + - expat 2.7.3.* + license: MIT + license_family: MIT + purls: [] + size: 76201 + timestamp: 1763549910086 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libffi-3.5.2-h3435931_0.conda + sha256: 31f19b6a88ce40ebc0d5a992c131f57d919f73c0b92cd1617a5bec83f6e961e6 + md5: a360c33a5abe61c07959e449fa1453eb + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + license: MIT + license_family: MIT + purls: [] + size: 58592 + timestamp: 1769456073053 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libffi-3.5.2-h376a255_0.conda + sha256: 3df4c539449aabc3443bbe8c492c01d401eea894603087fca2917aa4e1c2dea9 + md5: 2f364feefb6a7c00423e80dcb12db62a + depends: + - libgcc >=14 + license: MIT + license_family: MIT + purls: [] + size: 55952 + timestamp: 1769456078358 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-15.2.0-he0feb66_17.conda + sha256: 43860222cf3abf04ded0cf24541a105aa388e0e1d4d6ca46258e186d4e87ae3e + md5: 3c281169ea25b987311400d7a7e28445 + depends: + - __glibc >=2.17,<3.0.a0 + - _openmp_mutex >=4.5 + constrains: + - libgcc-ng ==15.2.0=*_17 + - libgomp 15.2.0 he0feb66_17 + license: GPL-3.0-only WITH GCC-exception-3.1 + purls: [] + size: 1040478 + timestamp: 1770252533873 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libgcc-15.2.0-h8acb6b2_17.conda + sha256: 3709e656917d282b5d3d78928a7a101bd638aaa9d17fb8689e6f95428d519056 + md5: 0d842d2053b95a6dbb83554948e7cbfe + depends: + - _openmp_mutex >=4.5 + constrains: + - libgomp 15.2.0 h8acb6b2_17 + - libgcc-ng ==15.2.0=*_17 + license: GPL-3.0-only WITH GCC-exception-3.1 + purls: [] + size: 622154 + timestamp: 1770252127670 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-ng-15.2.0-h69a702a_17.conda + sha256: bdfe50501e4a2d904a5eae65a7ae26e2b7a29b473ab084ad55d96080b966502e + md5: 1478bfa85224a65ab096d69ffd2af1e5 + depends: + - libgcc 15.2.0 he0feb66_17 + license: GPL-3.0-only WITH GCC-exception-3.1 + purls: [] + size: 27541 + timestamp: 1770252546553 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libgcc-ng-15.2.0-he9431aa_17.conda + sha256: 308bd5fe9cd4b19b08c07437a626cf02a1d70a43216d68469d17003aece63202 + md5: bcd31f4a57798bd158dfc0647fa77ca3 + depends: + - libgcc 15.2.0 h8acb6b2_17 + license: GPL-3.0-only WITH GCC-exception-3.1 + purls: [] + size: 27555 + timestamp: 1770252134574 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libgomp-15.2.0-he0feb66_17.conda + sha256: b961b5dd9761907a7179678b58a69bb4fc16b940eb477f635aea3aec0a3f17a6 + md5: 51b78c6a757575c0d12f4401ffc67029 + depends: + - __glibc >=2.17,<3.0.a0 + license: GPL-3.0-only WITH GCC-exception-3.1 + purls: [] + size: 603334 + timestamp: 1770252441199 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libgomp-15.2.0-h8acb6b2_17.conda + sha256: ffa6169ef78e5333ecb08152141932c5f8ca4f4d3742b1a5eec67173e70b907a + md5: 0ad9074c91b35dbf8b58bc68a9f9bda0 + license: GPL-3.0-only WITH GCC-exception-3.1 + purls: [] + size: 587183 + timestamp: 1770252042141 +- conda: https://conda.anaconda.org/conda-forge/linux-64/liblzma-5.8.2-hb03c661_0.conda + sha256: 755c55ebab181d678c12e49cced893598f2bab22d582fbbf4d8b83c18be207eb + md5: c7c83eecbb72d88b940c249af56c8b17 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + constrains: + - xz 5.8.2.* + license: 0BSD + purls: [] + size: 113207 + timestamp: 1768752626120 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/liblzma-5.8.2-he30d5cf_0.conda + sha256: 843c46e20519651a3e357a8928352b16c5b94f4cd3d5481acc48be2e93e8f6a3 + md5: 96944e3c92386a12755b94619bae0b35 + depends: + - libgcc >=14 + constrains: + - xz 5.8.2.* + license: 0BSD + purls: [] + size: 125916 + timestamp: 1768754941722 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libnsl-2.0.1-hb9d3cd8_1.conda + sha256: 927fe72b054277cde6cb82597d0fcf6baf127dcbce2e0a9d8925a68f1265eef5 + md5: d864d34357c3b65a4b731f78c0801dc4 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=13 + license: LGPL-2.1-only + license_family: GPL + purls: [] + size: 33731 + timestamp: 1750274110928 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libnsl-2.0.1-h86ecc28_1.conda + sha256: c0dc4d84198e3eef1f37321299e48e2754ca83fd12e6284754e3cb231357c3a5 + md5: d5d58b2dc3e57073fe22303f5fed4db7 + depends: + - libgcc >=13 + license: LGPL-2.1-only + license_family: GPL + purls: [] + size: 34831 + timestamp: 1750274211 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.51.2-hf4e2dac_0.conda + sha256: 04596fcee262a870e4b7c9807224680ff48d4d0cc0dac076a602503d3dc6d217 + md5: da5be73701eecd0e8454423fd6ffcf30 + depends: + - __glibc >=2.17,<3.0.a0 + - icu >=78.2,<79.0a0 + - libgcc >=14 + - libzlib >=1.3.1,<2.0a0 + license: blessing + purls: [] + size: 942808 + timestamp: 1768147973361 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libsqlite-3.51.2-h10b116e_0.conda + sha256: 5f8230ccaf9ffaab369adc894ef530699e96111dac0a8ff9b735a871f8ba8f8b + md5: 4e3ba0d5d192f99217b85f07a0761e64 + depends: + - icu >=78.2,<79.0a0 + - libgcc >=14 + - libzlib >=1.3.1,<2.0a0 + license: blessing + purls: [] + size: 944688 + timestamp: 1768147991301 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-15.2.0-h934c35e_17.conda + sha256: 50c48cd3716a2e58e8e2e02edc78fef2d08fffe1e3b1ed40eb5f87e7e2d07889 + md5: 24c2fe35fa45cd71214beba6f337c071 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc 15.2.0 he0feb66_17 + constrains: + - libstdcxx-ng ==15.2.0=*_17 + license: GPL-3.0-only WITH GCC-exception-3.1 + purls: [] + size: 5852406 + timestamp: 1770252584235 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libstdcxx-15.2.0-hef695bb_17.conda + sha256: fe425c07aa3116e7d5f537efe010d545bb43ac120cb565fbfb5ece8cb725aa80 + md5: 500d275cb616d81b5d9828aedffc4bc3 + depends: + - libgcc 15.2.0 h8acb6b2_17 + constrains: + - libstdcxx-ng ==15.2.0=*_17 + license: GPL-3.0-only WITH GCC-exception-3.1 + purls: [] + size: 5542688 + timestamp: 1770252159760 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libuuid-2.41.3-h5347b49_0.conda + sha256: 1a7539cfa7df00714e8943e18de0b06cceef6778e420a5ee3a2a145773758aee + md5: db409b7c1720428638e7c0d509d3e1b5 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + license: BSD-3-Clause + license_family: BSD + purls: [] + size: 40311 + timestamp: 1766271528534 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libuuid-2.41.3-h1022ec0_0.conda + sha256: c37a8e89b700646f3252608f8368e7eb8e2a44886b92776e57ad7601fc402a11 + md5: cf2861212053d05f27ec49c3784ff8bb + depends: + - libgcc >=14 + license: BSD-3-Clause + license_family: BSD + purls: [] + size: 43453 + timestamp: 1766271546875 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libxcrypt-4.4.36-hd590300_1.conda + sha256: 6ae68e0b86423ef188196fff6207ed0c8195dd84273cb5623b85aa08033a410c + md5: 5aa797f8787fe7a17d1b0821485b5adc + depends: + - libgcc-ng >=12 + license: LGPL-2.1-or-later + purls: [] + size: 100393 + timestamp: 1702724383534 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libxcrypt-4.4.36-h31becfc_1.conda + sha256: 6b46c397644091b8a26a3048636d10b989b1bf266d4be5e9474bf763f828f41f + md5: b4df5d7d4b63579d081fd3a4cf99740e + depends: + - libgcc-ng >=12 + license: LGPL-2.1-or-later + purls: [] + size: 114269 + timestamp: 1702724369203 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.3.1-hb9d3cd8_2.conda + sha256: d4bfe88d7cb447768e31650f06257995601f89076080e76df55e3112d4e47dc4 + md5: edb0dca6bc32e4f4789199455a1dbeb8 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=13 + constrains: + - zlib 1.3.1 *_2 + license: Zlib + license_family: Other + purls: [] + size: 60963 + timestamp: 1727963148474 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libzlib-1.3.1-h86ecc28_2.conda + sha256: 5a2c1eeef69342e88a98d1d95bff1603727ab1ff4ee0e421522acd8813439b84 + md5: 08aad7cbe9f5a6b460d0976076b6ae64 + depends: + - libgcc >=13 + constrains: + - zlib 1.3.1 *_2 + license: Zlib + license_family: Other + purls: [] + size: 66657 + timestamp: 1727963199518 +- pypi: https://files.pythonhosted.org/packages/00/f9/7638f5cc82ec8a7aa005de48622eecc3ed7c9854b96ba15bd76b7fd27574/matplotlib-3.10.8-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl + name: matplotlib + version: 3.10.8 + sha256: 24d50994d8c5816ddc35411e50a86ab05f575e2530c02752e02538122613371f + requires_dist: + - contourpy>=1.0.1 + - cycler>=0.10 + - fonttools>=4.22.0 + - kiwisolver>=1.3.1 + - numpy>=1.23 + - packaging>=20.0 + - pillow>=8 + - pyparsing>=3 + - python-dateutil>=2.7 + - meson-python>=0.13.1,<0.17.0 ; extra == 'dev' + - pybind11>=2.13.2,!=2.13.3 ; extra == 'dev' + - setuptools-scm>=7 ; extra == 'dev' + - setuptools>=64 ; extra == 'dev' + requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/3e/f3/c5195b1ae57ef85339fd7285dfb603b22c8b4e79114bae5f4f0fcf688677/matplotlib-3.10.8-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl + name: matplotlib + version: 3.10.8 + sha256: 3ab4aabc72de4ff77b3ec33a6d78a68227bf1123465887f9905ba79184a1cc04 + requires_dist: + - contourpy>=1.0.1 + - cycler>=0.10 + - fonttools>=4.22.0 + - kiwisolver>=1.3.1 + - numpy>=1.23 + - packaging>=20.0 + - pillow>=8 + - pyparsing>=3 + - python-dateutil>=2.7 + - meson-python>=0.13.1,<0.17.0 ; extra == 'dev' + - pybind11>=2.13.2,!=2.13.3 ; extra == 'dev' + - setuptools-scm>=7 ; extra == 'dev' + - setuptools>=64 ; extra == 'dev' + requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/a8/f2/c8f62565abc93b9ac6a9936856d3c3c144c7f7896ef3d02bfbfad2ab6ee7/mediapipe-0.10.18-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl + name: mediapipe + version: 0.10.18 + sha256: 09cbf7dc1f9a2deeaaac687e5f982836623def4cbd3e827d95f86f42450d2dd1 + requires_dist: + - absl-py + - attrs>=19.1.0 + - flatbuffers>=2.0 + - jax + - jaxlib + - matplotlib + - numpy<2 + - opencv-contrib-python + - protobuf>=4.25.3,<5 + - sounddevice>=0.4.4 + - sentencepiece +- pypi: https://files.pythonhosted.org/packages/e3/98/00cd8b2dcb563f2298655633e6611a791b2c1a7df1dae064b2b96084f1bf/mediapipe-0.10.32-py3-none-manylinux_2_28_x86_64.whl + name: mediapipe + version: 0.10.32 + sha256: 4b0941fbbbce41862f13cb1850c4878c13dbc62cd5e81e74880051b7a20ce3b6 + requires_dist: + - absl-py~=2.3 + - numpy + - sounddevice~=0.5 + - flatbuffers~=25.9 + - opencv-contrib-python + - matplotlib +- pypi: https://files.pythonhosted.org/packages/54/0f/428ef6881782e5ebb7eca459689448c0394fa0a80bea3aa9262cba5445ea/ml_dtypes-0.5.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl + name: ml-dtypes + version: 0.5.4 + sha256: a7f7c643e8b1320fd958bf098aa7ecf70623a42ec5154e3be3be673f4c34d900 + requires_dist: + - numpy>=1.21 + - numpy>=1.21.2 ; python_full_version >= '3.10' + - numpy>=1.23.3 ; python_full_version >= '3.11' + - numpy>=1.26.0 ; python_full_version >= '3.12' + - numpy>=2.1.0 ; python_full_version >= '3.13' + - absl-py ; extra == 'dev' + - pytest ; extra == 'dev' + - pytest-xdist ; extra == 'dev' + - pylint>=2.6.0 ; extra == 'dev' + - pyink ; extra == 'dev' + requires_python: '>=3.9' +- conda: https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.5-h2d0b736_3.conda + sha256: 3fde293232fa3fca98635e1167de6b7c7fda83caf24b9d6c91ec9eefb4f4d586 + md5: 47e340acb35de30501a76c7c799c41d7 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=13 + license: X11 AND BSD-3-Clause + purls: [] + size: 891641 + timestamp: 1738195959188 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/ncurses-6.5-ha32ae93_3.conda + sha256: 91cfb655a68b0353b2833521dc919188db3d8a7f4c64bea2c6a7557b24747468 + md5: 182afabe009dc78d8b73100255ee6868 + depends: + - libgcc >=13 + license: X11 AND BSD-3-Clause + purls: [] + size: 926034 + timestamp: 1738196018799 +- conda: https://conda.anaconda.org/conda-forge/noarch/nodeenv-1.10.0-pyhd8ed1ab_0.conda + sha256: 4fa40e3e13fc6ea0a93f67dfc76c96190afd7ea4ffc1bac2612d954b42cdc3ee + md5: eb52d14a901e23c39e9e7b4a1a5c015f + depends: + - python >=3.10 + - setuptools + license: BSD-3-Clause + license_family: BSD + purls: + - pkg:pypi/nodeenv?source=hash-mapping + size: 40866 + timestamp: 1766261270149 +- pypi: https://files.pythonhosted.org/packages/79/f8/97f10e6755e2a7d027ca783f63044d5b1bc1ae7acb12afe6a9b4286eac17/numpy-1.26.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl + name: numpy + version: 1.26.4 + sha256: 9fad7dcb1aac3c7f0584a5a8133e3a43eeb2fe127f47e3632d43d677c66c102b + requires_python: '>=3.9' +- pypi: https://files.pythonhosted.org/packages/f5/c6/a18e59f3f0b8071cc85cbc8d80cd02d68aa9710170b2553a117203d46936/numpy-2.4.2-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl + name: numpy + version: 2.4.2 + sha256: 9e35d3e0144137d9fdae62912e869136164534d64a169f86438bc9561b6ad49f + requires_python: '>=3.11' +- pypi: https://files.pythonhosted.org/packages/ae/7b/7e1471aa92f9f3c1bd8dbe624622b62add6f734db34fbbb9974e2ec70c34/opencv_contrib_python-4.11.0.86-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl + name: opencv-contrib-python + version: 4.11.0.86 + sha256: f21034bc8b00eb286a0a0a92b99767bf596bfe426cf4bc2e79647d64ad0dd6da + requires_dist: + - numpy>=1.13.3 ; python_full_version < '3.7' + - numpy>=1.21.0 ; python_full_version < '3.10' and platform_machine == 'arm64' and sys_platform == 'darwin' + - numpy>=1.21.2 ; python_full_version >= '3.10' + - numpy>=1.21.4 ; python_full_version >= '3.10' and sys_platform == 'darwin' + - numpy>=1.23.5 ; python_full_version >= '3.11' + - numpy>=1.26.0 ; python_full_version >= '3.12' + - numpy>=1.19.3 ; python_full_version >= '3.6' and platform_machine == 'aarch64' and sys_platform == 'linux' + - numpy>=1.17.0 ; python_full_version >= '3.7' + - numpy>=1.17.3 ; python_full_version >= '3.8' + - numpy>=1.19.3 ; python_full_version >= '3.9' + requires_python: '>=3.6' +- pypi: https://files.pythonhosted.org/packages/a9/3d/00071f3a395611a13efca22e3ee65aab25b8bf54128ae5080d8361cbb673/opencv_contrib_python-4.13.0.90-cp37-abi3-manylinux_2_28_x86_64.whl + name: opencv-contrib-python + version: 4.13.0.90 + sha256: 3c693f1fb7a25eae73eb9bc1c2fdbc08ad3df51c31589f1f9a8377f2a4368b1c + requires_dist: + - numpy<2.0 ; python_full_version < '3.9' + - numpy>=2 ; python_full_version >= '3.9' + requires_python: '>=3.6' +- conda: https://conda.anaconda.org/conda-forge/linux-64/openssl-3.6.1-h35e630c_1.conda + sha256: 44c877f8af015332a5d12f5ff0fb20ca32f896526a7d0cdb30c769df1144fb5c + md5: f61eb8cd60ff9057122a3d338b99c00f + depends: + - __glibc >=2.17,<3.0.a0 + - ca-certificates + - libgcc >=14 + license: Apache-2.0 + license_family: Apache + purls: [] + size: 3164551 + timestamp: 1769555830639 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/openssl-3.6.1-h546c87b_1.conda + sha256: 7f8048c0e75b2620254218d72b4ae7f14136f1981c5eb555ef61645a9344505f + md5: 25f5885f11e8b1f075bccf4a2da91c60 + depends: + - ca-certificates + - libgcc >=14 + license: Apache-2.0 + license_family: Apache + purls: [] + size: 3692030 + timestamp: 1769557678657 +- pypi: https://files.pythonhosted.org/packages/23/cd/066e86230ae37ed0be70aae89aabf03ca8d9f39c8aea0dec8029455b5540/opt_einsum-3.4.0-py3-none-any.whl + name: opt-einsum + version: 3.4.0 + sha256: 69bb92469f86a1565195ece4ac0323943e83477171b91d24c35afe028a90d7cd + requires_python: '>=3.8' +- conda: https://conda.anaconda.org/conda-forge/noarch/packaging-26.0-pyhcf101f3_0.conda + sha256: c1fc0f953048f743385d31c468b4a678b3ad20caffdeaa94bed85ba63049fd58 + md5: b76541e68fea4d511b1ac46a28dcd2c6 + depends: + - python >=3.8 + - python + license: Apache-2.0 + license_family: APACHE + purls: + - pkg:pypi/packaging?source=compressed-mapping + size: 72010 + timestamp: 1769093650580 +- pypi: https://files.pythonhosted.org/packages/0a/ea/f200a4c36d836100e7bc738fc48cd963d3ba6372ebc8298a889e0cfc3359/pillow-12.1.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl + name: pillow + version: 12.1.0 + sha256: 742aea052cf5ab5034a53c3846165bc3ce88d7c38e954120db0ab867ca242661 + requires_dist: + - furo ; extra == 'docs' + - olefile ; extra == 'docs' + - sphinx>=8.2 ; extra == 'docs' + - sphinx-autobuild ; extra == 'docs' + - sphinx-copybutton ; extra == 'docs' + - sphinx-inline-tabs ; extra == 'docs' + - sphinxext-opengraph ; extra == 'docs' + - olefile ; extra == 'fpx' + - olefile ; extra == 'mic' + - arro3-compute ; extra == 'test-arrow' + - arro3-core ; extra == 'test-arrow' + - nanoarrow ; extra == 'test-arrow' + - pyarrow ; extra == 'test-arrow' + - check-manifest ; extra == 'tests' + - coverage>=7.4.2 ; extra == 'tests' + - defusedxml ; extra == 'tests' + - markdown2 ; extra == 'tests' + - olefile ; extra == 'tests' + - packaging ; extra == 'tests' + - pyroma>=5 ; extra == 'tests' + - pytest ; extra == 'tests' + - pytest-cov ; extra == 'tests' + - pytest-timeout ; extra == 'tests' + - pytest-xdist ; extra == 'tests' + - trove-classifiers>=2024.10.12 ; extra == 'tests' + - defusedxml ; extra == 'xmp' + requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/11/8f/48d0b77ab2200374c66d344459b8958c86693be99526450e7aee714e03e4/pillow-12.1.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl + name: pillow + version: 12.1.0 + sha256: a6dfc2af5b082b635af6e08e0d1f9f1c4e04d17d4e2ca0ef96131e85eda6eb17 + requires_dist: + - furo ; extra == 'docs' + - olefile ; extra == 'docs' + - sphinx>=8.2 ; extra == 'docs' + - sphinx-autobuild ; extra == 'docs' + - sphinx-copybutton ; extra == 'docs' + - sphinx-inline-tabs ; extra == 'docs' + - sphinxext-opengraph ; extra == 'docs' + - olefile ; extra == 'fpx' + - olefile ; extra == 'mic' + - arro3-compute ; extra == 'test-arrow' + - arro3-core ; extra == 'test-arrow' + - nanoarrow ; extra == 'test-arrow' + - pyarrow ; extra == 'test-arrow' + - check-manifest ; extra == 'tests' + - coverage>=7.4.2 ; extra == 'tests' + - defusedxml ; extra == 'tests' + - markdown2 ; extra == 'tests' + - olefile ; extra == 'tests' + - packaging ; extra == 'tests' + - pyroma>=5 ; extra == 'tests' + - pytest ; extra == 'tests' + - pytest-cov ; extra == 'tests' + - pytest-timeout ; extra == 'tests' + - pytest-xdist ; extra == 'tests' + - trove-classifiers>=2024.10.12 ; extra == 'tests' + - defusedxml ; extra == 'xmp' + requires_python: '>=3.10' +- conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.5.1-pyhcf101f3_0.conda + sha256: 04c64fb78c520e5c396b6e07bc9082735a5cc28175dbe23138201d0a9441800b + md5: 1bd2e65c8c7ef24f4639ae6e850dacc2 + depends: + - python >=3.10 + - python + license: MIT + license_family: MIT + purls: + - pkg:pypi/platformdirs?source=hash-mapping + size: 23922 + timestamp: 1764950726246 +- conda: https://conda.anaconda.org/conda-forge/noarch/pluggy-1.6.0-pyhf9edf01_1.conda + sha256: e14aafa63efa0528ca99ba568eaf506eb55a0371d12e6250aaaa61718d2eb62e + md5: d7585b6550ad04c8c5e21097ada2888e + depends: + - python >=3.9 + - python + license: MIT + license_family: MIT + purls: + - pkg:pypi/pluggy?source=compressed-mapping + size: 25877 + timestamp: 1764896838868 +- conda: https://conda.anaconda.org/conda-forge/noarch/pre-commit-4.5.1-pyha770c72_0.conda + sha256: 5b81b7516d4baf43d0c185896b245fa7384b25dc5615e7baa504b7fa4e07b706 + md5: 7f3ac694319c7eaf81a0325d6405e974 + depends: + - cfgv >=2.0.0 + - identify >=1.0.0 + - nodeenv >=0.11.1 + - python >=3.10 + - pyyaml >=5.1 + - virtualenv >=20.10.0 + license: MIT + license_family: MIT + purls: + - pkg:pypi/pre-commit?source=hash-mapping + size: 200827 + timestamp: 1765937577534 +- pypi: https://files.pythonhosted.org/packages/bd/6d/a4a198b61808dd3d1ee187082ccc21499bc949d639feb948961b48be9a7e/protobuf-4.25.8-cp37-abi3-manylinux2014_aarch64.whl + name: protobuf + version: 4.25.8 + sha256: 9ad7ef62d92baf5a8654fbb88dac7fa5594cfa70fd3440488a5ca3bfc6d795a7 + requires_python: '>=3.8' +- conda: https://conda.anaconda.org/conda-forge/noarch/pycparser-2.22-pyh29332c3_1.conda + sha256: 79db7928d13fab2d892592223d7570f5061c192f27b9febd1a418427b719acc6 + md5: 12c566707c80111f9799308d9e265aef + depends: + - python >=3.9 + - python + license: BSD-3-Clause + license_family: BSD + purls: + - pkg:pypi/pycparser?source=hash-mapping + size: 110100 + timestamp: 1733195786147 +- conda: https://conda.anaconda.org/conda-forge/noarch/pygments-2.19.2-pyhd8ed1ab_0.conda + sha256: 5577623b9f6685ece2697c6eb7511b4c9ac5fb607c9babc2646c811b428fd46a + md5: 6b6ece66ebcae2d5f326c77ef2c5a066 + depends: + - python >=3.9 + license: BSD-2-Clause + license_family: BSD + purls: + - pkg:pypi/pygments?source=hash-mapping + size: 889287 + timestamp: 1750615908735 +- pypi: https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl + name: pyparsing + version: 3.3.2 + sha256: 850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d + requires_dist: + - railroad-diagrams ; extra == 'diagrams' + - jinja2 ; extra == 'diagrams' + requires_python: '>=3.9' +- conda: https://conda.anaconda.org/conda-forge/noarch/pytest-9.0.2-pyhcf101f3_0.conda + sha256: 9e749fb465a8bedf0184d8b8996992a38de351f7c64e967031944978de03a520 + md5: 2b694bad8a50dc2f712f5368de866480 + depends: + - pygments >=2.7.2 + - python >=3.10 + - iniconfig >=1.0.1 + - packaging >=22 + - pluggy >=1.5,<2 + - tomli >=1 + - colorama >=0.4 + - exceptiongroup >=1 + - python + constrains: + - pytest-faulthandler >=2 + license: MIT + license_family: MIT + purls: + - pkg:pypi/pytest?source=hash-mapping + size: 299581 + timestamp: 1765062031645 +- conda: https://conda.anaconda.org/conda-forge/noarch/pytest-timeout-2.4.0-pyhd8ed1ab_0.conda + sha256: 25afa7d9387f2aa151b45eb6adf05f9e9e3f58c8de2bc09be7e85c114118eeb9 + md5: 52a50ca8ea1b3496fbd3261bea8c5722 + depends: + - pytest >=7.0.0 + - python >=3.9 + license: MIT + license_family: MIT + purls: + - pkg:pypi/pytest-timeout?source=hash-mapping + size: 20137 + timestamp: 1746533140824 +- conda: https://conda.anaconda.org/conda-forge/linux-64/python-3.12.12-hd63d673_2_cpython.conda + build_number: 2 + sha256: 6621befd6570a216ba94bc34ec4618e4f3777de55ad0adc15fc23c28fadd4d1a + md5: c4540d3de3fa228d9fa95e31f8e97f89 + depends: + - __glibc >=2.17,<3.0.a0 + - bzip2 >=1.0.8,<2.0a0 + - ld_impl_linux-64 >=2.36.1 + - libexpat >=2.7.3,<3.0a0 + - libffi >=3.5.2,<3.6.0a0 + - libgcc >=14 + - liblzma >=5.8.2,<6.0a0 + - libnsl >=2.0.1,<2.1.0a0 + - libsqlite >=3.51.2,<4.0a0 + - libuuid >=2.41.3,<3.0a0 + - libxcrypt >=4.4.36 + - libzlib >=1.3.1,<2.0a0 + - ncurses >=6.5,<7.0a0 + - openssl >=3.5.4,<4.0a0 + - readline >=8.3,<9.0a0 + - tk >=8.6.13,<8.7.0a0 + - tzdata + constrains: + - python_abi 3.12.* *_cp312 + license: Python-2.0 + purls: [] + size: 31457785 + timestamp: 1769472855343 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/python-3.12.12-h91f4b29_2_cpython.conda + build_number: 2 + sha256: b67569e1d6ce065e1d246a38a0c92bcc9f43cf003a6d67a57e7db7240714c5ce + md5: b75f79be54a1422f1259bb7d198e8697 + depends: + - bzip2 >=1.0.8,<2.0a0 + - ld_impl_linux-aarch64 >=2.36.1 + - libexpat >=2.7.3,<3.0a0 + - libffi >=3.5.2,<3.6.0a0 + - libgcc >=14 + - liblzma >=5.8.2,<6.0a0 + - libnsl >=2.0.1,<2.1.0a0 + - libsqlite >=3.51.2,<4.0a0 + - libuuid >=2.41.3,<3.0a0 + - libxcrypt >=4.4.36 + - libzlib >=1.3.1,<2.0a0 + - ncurses >=6.5,<7.0a0 + - openssl >=3.5.4,<4.0a0 + - readline >=8.3,<9.0a0 + - tk >=8.6.13,<8.7.0a0 + - tzdata + constrains: + - python_abi 3.12.* *_cp312 + license: Python-2.0 + purls: [] + size: 13798340 + timestamp: 1769471112 +- pypi: https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl + name: python-dateutil + version: 2.9.0.post0 + sha256: a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427 + requires_dist: + - six>=1.5 + requires_python: '>=2.7,!=3.0.*,!=3.1.*,!=3.2.*' +- conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.12-8_cp312.conda + build_number: 8 + sha256: 80677180dd3c22deb7426ca89d6203f1c7f1f256f2d5a94dc210f6e758229809 + md5: c3efd25ac4d74b1584d2f7a57195ddf1 + constrains: + - python 3.12.* *_cpython + license: BSD-3-Clause + license_family: BSD + purls: [] + size: 6958 + timestamp: 1752805918820 +- conda: https://conda.anaconda.org/conda-forge/linux-64/pyyaml-6.0.3-py312h8a5da7c_1.conda + sha256: cb142bfd92f6e55749365ddc244294fa7b64db6d08c45b018ff1c658907bfcbf + md5: 15878599a87992e44c059731771591cb + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - python >=3.12,<3.13.0a0 + - python_abi 3.12.* *_cp312 + - yaml >=0.2.5,<0.3.0a0 + license: MIT + license_family: MIT + purls: + - pkg:pypi/pyyaml?source=compressed-mapping + size: 198293 + timestamp: 1770223620706 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/pyyaml-6.0.3-py312ha4530ae_1.conda + sha256: 0ba02720b470150a8c6261a86ea4db01dcf121e16a3e3978a84e965d3fe9c39a + md5: 47018c13dbb26186b577fd8bd1823a44 + depends: + - libgcc >=14 + - python >=3.12,<3.13.0a0 + - python >=3.12,<3.13.0a0 *_cpython + - python_abi 3.12.* *_cp312 + - yaml >=0.2.5,<0.3.0a0 + license: MIT + license_family: MIT + purls: + - pkg:pypi/pyyaml?source=compressed-mapping + size: 192182 + timestamp: 1770223431156 +- conda: https://conda.anaconda.org/conda-forge/linux-64/readline-8.3-h853b02a_0.conda + sha256: 12ffde5a6f958e285aa22c191ca01bbd3d6e710aa852e00618fa6ddc59149002 + md5: d7d95fc8287ea7bf33e0e7116d2b95ec + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - ncurses >=6.5,<7.0a0 + license: GPL-3.0-only + license_family: GPL + purls: [] + size: 345073 + timestamp: 1765813471974 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/readline-8.3-hb682ff5_0.conda + sha256: fe695f9d215e9a2e3dd0ca7f56435ab4df24f5504b83865e3d295df36e88d216 + md5: 3d49cad61f829f4f0e0611547a9cda12 + depends: + - libgcc >=14 + - ncurses >=6.5,<7.0a0 + license: GPL-3.0-only + license_family: GPL + purls: [] + size: 357597 + timestamp: 1765815673644 +- conda: https://conda.anaconda.org/conda-forge/linux-64/ruff-0.15.0-h40fa522_0.conda + noarch: python + sha256: fc456645570586c798d2da12fe723b38ea0d0901373fd9959cab914cbb19518b + md5: fe90be2abf12b301dde984719a02ca0b + depends: + - python + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + constrains: + - __glibc >=2.17 + license: MIT + license_family: MIT + purls: + - pkg:pypi/ruff?source=compressed-mapping + size: 9103793 + timestamp: 1770153712370 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/ruff-0.15.0-he9a2e21_0.conda + noarch: python + sha256: 803420e8772f977773d16c4f65a408e1164f24dbfcc7ee5ecf3186f26a29266f + md5: 14237324ec9136969cf5c82ca36915a1 + depends: + - python + - libgcc >=14 + constrains: + - __glibc >=2.17 + license: MIT + license_family: MIT + purls: + - pkg:pypi/ruff?source=hash-mapping + size: 8771882 + timestamp: 1770153714339 +- pypi: https://files.pythonhosted.org/packages/97/74/b7a304feb2b49df9fafa9382d4d09061a96ee9a9449a7cbea7988dda0828/scikit_learn-1.8.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl + name: scikit-learn + version: 1.8.0 + sha256: a0bcfe4d0d14aec44921545fd2af2338c7471de9cb701f1da4c9d85906ab847a + requires_dist: + - numpy>=1.24.1 + - scipy>=1.10.0 + - joblib>=1.3.0 + - threadpoolctl>=3.2.0 + - numpy>=1.24.1 ; extra == 'build' + - scipy>=1.10.0 ; extra == 'build' + - cython>=3.1.2 ; extra == 'build' + - meson-python>=0.17.1 ; extra == 'build' + - numpy>=1.24.1 ; extra == 'install' + - scipy>=1.10.0 ; extra == 'install' + - joblib>=1.3.0 ; extra == 'install' + - threadpoolctl>=3.2.0 ; extra == 'install' + - matplotlib>=3.6.1 ; extra == 'benchmark' + - pandas>=1.5.0 ; extra == 'benchmark' + - memory-profiler>=0.57.0 ; extra == 'benchmark' + - matplotlib>=3.6.1 ; extra == 'docs' + - scikit-image>=0.22.0 ; extra == 'docs' + - pandas>=1.5.0 ; extra == 'docs' + - seaborn>=0.13.0 ; extra == 'docs' + - memory-profiler>=0.57.0 ; extra == 'docs' + - sphinx>=7.3.7 ; extra == 'docs' + - sphinx-copybutton>=0.5.2 ; extra == 'docs' + - sphinx-gallery>=0.17.1 ; extra == 'docs' + - numpydoc>=1.2.0 ; extra == 'docs' + - pillow>=10.1.0 ; extra == 'docs' + - pooch>=1.8.0 ; extra == 'docs' + - sphinx-prompt>=1.4.0 ; extra == 'docs' + - sphinxext-opengraph>=0.9.1 ; extra == 'docs' + - plotly>=5.18.0 ; extra == 'docs' + - polars>=0.20.30 ; extra == 'docs' + - sphinx-design>=0.6.0 ; extra == 'docs' + - sphinxcontrib-sass>=0.3.4 ; extra == 'docs' + - pydata-sphinx-theme>=0.15.3 ; extra == 'docs' + - sphinx-remove-toctrees>=1.0.0.post1 ; extra == 'docs' + - towncrier>=24.8.0 ; extra == 'docs' + - matplotlib>=3.6.1 ; extra == 'examples' + - scikit-image>=0.22.0 ; extra == 'examples' + - pandas>=1.5.0 ; extra == 'examples' + - seaborn>=0.13.0 ; extra == 'examples' + - pooch>=1.8.0 ; extra == 'examples' + - plotly>=5.18.0 ; extra == 'examples' + - matplotlib>=3.6.1 ; extra == 'tests' + - pandas>=1.5.0 ; extra == 'tests' + - pytest>=7.1.2 ; extra == 'tests' + - pytest-cov>=2.9.0 ; extra == 'tests' + - ruff>=0.11.7 ; extra == 'tests' + - mypy>=1.15 ; extra == 'tests' + - pyamg>=5.0.0 ; extra == 'tests' + - polars>=0.20.30 ; extra == 'tests' + - pyarrow>=12.0.0 ; extra == 'tests' + - numpydoc>=1.2.0 ; extra == 'tests' + - pooch>=1.8.0 ; extra == 'tests' + - conda-lock==3.0.1 ; extra == 'maintenance' + requires_python: '>=3.11' +- pypi: https://files.pythonhosted.org/packages/dd/47/f187b4636ff80cc63f21cd40b7b2d177134acaa10f6bb73746130ee8c2e5/scikit_learn-1.8.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl + name: scikit-learn + version: 1.8.0 + sha256: 4496bb2cf7a43ce1a2d7524a79e40bc5da45cf598dbf9545b7e8316ccba47bb4 + requires_dist: + - numpy>=1.24.1 + - scipy>=1.10.0 + - joblib>=1.3.0 + - threadpoolctl>=3.2.0 + - numpy>=1.24.1 ; extra == 'build' + - scipy>=1.10.0 ; extra == 'build' + - cython>=3.1.2 ; extra == 'build' + - meson-python>=0.17.1 ; extra == 'build' + - numpy>=1.24.1 ; extra == 'install' + - scipy>=1.10.0 ; extra == 'install' + - joblib>=1.3.0 ; extra == 'install' + - threadpoolctl>=3.2.0 ; extra == 'install' + - matplotlib>=3.6.1 ; extra == 'benchmark' + - pandas>=1.5.0 ; extra == 'benchmark' + - memory-profiler>=0.57.0 ; extra == 'benchmark' + - matplotlib>=3.6.1 ; extra == 'docs' + - scikit-image>=0.22.0 ; extra == 'docs' + - pandas>=1.5.0 ; extra == 'docs' + - seaborn>=0.13.0 ; extra == 'docs' + - memory-profiler>=0.57.0 ; extra == 'docs' + - sphinx>=7.3.7 ; extra == 'docs' + - sphinx-copybutton>=0.5.2 ; extra == 'docs' + - sphinx-gallery>=0.17.1 ; extra == 'docs' + - numpydoc>=1.2.0 ; extra == 'docs' + - pillow>=10.1.0 ; extra == 'docs' + - pooch>=1.8.0 ; extra == 'docs' + - sphinx-prompt>=1.4.0 ; extra == 'docs' + - sphinxext-opengraph>=0.9.1 ; extra == 'docs' + - plotly>=5.18.0 ; extra == 'docs' + - polars>=0.20.30 ; extra == 'docs' + - sphinx-design>=0.6.0 ; extra == 'docs' + - sphinxcontrib-sass>=0.3.4 ; extra == 'docs' + - pydata-sphinx-theme>=0.15.3 ; extra == 'docs' + - sphinx-remove-toctrees>=1.0.0.post1 ; extra == 'docs' + - towncrier>=24.8.0 ; extra == 'docs' + - matplotlib>=3.6.1 ; extra == 'examples' + - scikit-image>=0.22.0 ; extra == 'examples' + - pandas>=1.5.0 ; extra == 'examples' + - seaborn>=0.13.0 ; extra == 'examples' + - pooch>=1.8.0 ; extra == 'examples' + - plotly>=5.18.0 ; extra == 'examples' + - matplotlib>=3.6.1 ; extra == 'tests' + - pandas>=1.5.0 ; extra == 'tests' + - pytest>=7.1.2 ; extra == 'tests' + - pytest-cov>=2.9.0 ; extra == 'tests' + - ruff>=0.11.7 ; extra == 'tests' + - mypy>=1.15 ; extra == 'tests' + - pyamg>=5.0.0 ; extra == 'tests' + - polars>=0.20.30 ; extra == 'tests' + - pyarrow>=12.0.0 ; extra == 'tests' + - numpydoc>=1.2.0 ; extra == 'tests' + - pooch>=1.8.0 ; extra == 'tests' + - conda-lock==3.0.1 ; extra == 'maintenance' + requires_python: '>=3.11' +- pypi: https://files.pythonhosted.org/packages/4a/69/7c347e857224fcaf32a34a05183b9d8a7aca25f8f2d10b8a698b8388561a/scipy-1.17.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl + name: scipy + version: 1.17.0 + sha256: 5194c445d0a1c7a6c1a4a4681b6b7c71baad98ff66d96b949097e7513c9d6742 + requires_dist: + - numpy>=1.26.4,<2.7 + - pytest>=8.0.0 ; extra == 'test' + - pytest-cov ; extra == 'test' + - pytest-timeout ; extra == 'test' + - pytest-xdist ; extra == 'test' + - asv ; extra == 'test' + - mpmath ; extra == 'test' + - gmpy2 ; extra == 'test' + - threadpoolctl ; extra == 'test' + - scikit-umfpack ; extra == 'test' + - pooch ; extra == 'test' + - hypothesis>=6.30 ; extra == 'test' + - array-api-strict>=2.3.1 ; extra == 'test' + - cython ; extra == 'test' + - meson ; extra == 'test' + - ninja ; sys_platform != 'emscripten' and extra == 'test' + - sphinx>=5.0.0,<8.2.0 ; extra == 'doc' + - intersphinx-registry ; extra == 'doc' + - pydata-sphinx-theme>=0.15.2 ; extra == 'doc' + - sphinx-copybutton ; extra == 'doc' + - sphinx-design>=0.4.0 ; extra == 'doc' + - matplotlib>=3.5 ; extra == 'doc' + - numpydoc ; extra == 'doc' + - jupytext ; extra == 'doc' + - myst-nb>=1.2.0 ; extra == 'doc' + - pooch ; extra == 'doc' + - jupyterlite-sphinx>=0.19.1 ; extra == 'doc' + - jupyterlite-pyodide-kernel ; extra == 'doc' + - linkify-it-py ; extra == 'doc' + - tabulate ; extra == 'doc' + - click<8.3.0 ; extra == 'dev' + - spin ; extra == 'dev' + - mypy==1.10.0 ; extra == 'dev' + - typing-extensions ; extra == 'dev' + - types-psutil ; extra == 'dev' + - pycodestyle ; extra == 'dev' + - ruff>=0.12.0 ; extra == 'dev' + - cython-lint>=0.12.2 ; extra == 'dev' + requires_python: '>=3.11' +- pypi: https://files.pythonhosted.org/packages/d1/fe/66d73b76d378ba8cc2fe605920c0c75092e3a65ae746e1e767d9d020a75a/scipy-1.17.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl + name: scipy + version: 1.17.0 + sha256: 9eeb9b5f5997f75507814ed9d298ab23f62cf79f5a3ef90031b1ee2506abdb5b + requires_dist: + - numpy>=1.26.4,<2.7 + - pytest>=8.0.0 ; extra == 'test' + - pytest-cov ; extra == 'test' + - pytest-timeout ; extra == 'test' + - pytest-xdist ; extra == 'test' + - asv ; extra == 'test' + - mpmath ; extra == 'test' + - gmpy2 ; extra == 'test' + - threadpoolctl ; extra == 'test' + - scikit-umfpack ; extra == 'test' + - pooch ; extra == 'test' + - hypothesis>=6.30 ; extra == 'test' + - array-api-strict>=2.3.1 ; extra == 'test' + - cython ; extra == 'test' + - meson ; extra == 'test' + - ninja ; sys_platform != 'emscripten' and extra == 'test' + - sphinx>=5.0.0,<8.2.0 ; extra == 'doc' + - intersphinx-registry ; extra == 'doc' + - pydata-sphinx-theme>=0.15.2 ; extra == 'doc' + - sphinx-copybutton ; extra == 'doc' + - sphinx-design>=0.4.0 ; extra == 'doc' + - matplotlib>=3.5 ; extra == 'doc' + - numpydoc ; extra == 'doc' + - jupytext ; extra == 'doc' + - myst-nb>=1.2.0 ; extra == 'doc' + - pooch ; extra == 'doc' + - jupyterlite-sphinx>=0.19.1 ; extra == 'doc' + - jupyterlite-pyodide-kernel ; extra == 'doc' + - linkify-it-py ; extra == 'doc' + - tabulate ; extra == 'doc' + - click<8.3.0 ; extra == 'dev' + - spin ; extra == 'dev' + - mypy==1.10.0 ; extra == 'dev' + - typing-extensions ; extra == 'dev' + - types-psutil ; extra == 'dev' + - pycodestyle ; extra == 'dev' + - ruff>=0.12.0 ; extra == 'dev' + - cython-lint>=0.12.2 ; extra == 'dev' + requires_python: '>=3.11' +- pypi: https://files.pythonhosted.org/packages/89/fa/d3d5ebcba3cb9e6d3775a096251860c41a6bc53a1b9461151df83fe93255/sentencepiece-0.2.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl + name: sentencepiece + version: 0.2.1 + sha256: 99f955df238021bf11f0fc37cdb54fd5e5b5f7fd30ecc3d93fb48b6815437167 + requires_dist: + - pytest ; extra == 'test' + - test ; extra == 'testpaths' + requires_python: '>=3.9' +- conda: https://conda.anaconda.org/conda-forge/noarch/setuptools-80.10.2-pyh332efcf_0.conda + sha256: f5fcb7854d2b7639a5b1aca41dd0f2d5a69a60bbc313e7f192e2dc385ca52f86 + md5: 7b446fcbb6779ee479debb4fd7453e6c + depends: + - python >=3.10 + license: MIT + license_family: MIT + purls: + - pkg:pypi/setuptools?source=compressed-mapping + size: 678888 + timestamp: 1769601206751 +- pypi: https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl + name: six + version: 1.17.0 + sha256: 4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274 + requires_python: '>=2.7,!=3.0.*,!=3.1.*,!=3.2.*' +- pypi: https://files.pythonhosted.org/packages/1e/0a/478e441fd049002cf308520c0d62dd8333e7c6cc8d997f0dda07b9fbcc46/sounddevice-0.5.5-py3-none-any.whl + name: sounddevice + version: 0.5.5 + sha256: 30ff99f6c107f49d25ad16a45cacd8d91c25a1bcdd3e81a206b921a3a6405b1f + requires_dist: + - cffi + - numpy ; extra == 'numpy' + requires_python: '>=3.7' +- pypi: https://files.pythonhosted.org/packages/32/d5/f9a850d79b0851d1d4ef6456097579a9005b31fea68726a4ae5f2d82ddd9/threadpoolctl-3.6.0-py3-none-any.whl + name: threadpoolctl + version: 3.6.0 + sha256: 43a0b8fd5a2928500110039e43a5eed8480b918967083ea48dc3ab9f13c4a7fb + requires_python: '>=3.9' +- conda: https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_h366c992_103.conda + sha256: cafeec44494f842ffeca27e9c8b0c27ed714f93ac77ddadc6aaf726b5554ebac + md5: cffd3bdd58090148f4cfcd831f4b26ab + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libzlib >=1.3.1,<2.0a0 + constrains: + - xorg-libx11 >=1.8.12,<2.0a0 + license: TCL + license_family: BSD + purls: [] + size: 3301196 + timestamp: 1769460227866 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/tk-8.6.13-noxft_h0dc03b3_103.conda + sha256: e25c314b52764219f842b41aea2c98a059f06437392268f09b03561e4f6e5309 + md5: 7fc6affb9b01e567d2ef1d05b84aa6ed + depends: + - libgcc >=14 + - libzlib >=1.3.1,<2.0a0 + constrains: + - xorg-libx11 >=1.8.12,<2.0a0 + license: TCL + license_family: BSD + purls: [] + size: 3368666 + timestamp: 1769464148928 +- conda: https://conda.anaconda.org/conda-forge/noarch/tomli-2.4.0-pyhcf101f3_0.conda + sha256: 62940c563de45790ba0f076b9f2085a842a65662268b02dd136a8e9b1eaf47a8 + md5: 72e780e9aa2d0a3295f59b1874e3768b + depends: + - python >=3.10 + - python + license: MIT + license_family: MIT + purls: + - pkg:pypi/tomli?source=compressed-mapping + size: 21453 + timestamp: 1768146676791 +- pypi: https://files.pythonhosted.org/packages/13/41/5bf882649bd8b64ded5fbce7fb8d77fb3b868de1a3b1a6c4796402b47308/ty-0.0.15-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl + name: ty + version: 0.0.15 + sha256: af87c3be7c944bb4d6609d6c63e4594944b0028c7bd490a525a82b88fe010d6d + requires_python: '>=3.8' +- pypi: https://files.pythonhosted.org/packages/3c/d9/244bc02599d950f7a4298fbc0c1b25cc808646b9577bdf7a83470b2d1cec/ty-0.0.15-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl + name: ty + version: 0.0.15 + sha256: 71f62a2644972975a657d9dc867bf901235cde51e8d24c20311067e7afd44a56 + requires_python: '>=3.8' +- conda: https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.15.0-pyhcf101f3_0.conda + sha256: 032271135bca55aeb156cee361c81350c6f3fb203f57d024d7e5a1fc9ef18731 + md5: 0caa1af407ecff61170c9437a808404d + depends: + - python >=3.10 + - python + license: PSF-2.0 + license_family: PSF + purls: + - pkg:pypi/typing-extensions?source=hash-mapping + size: 51692 + timestamp: 1756220668932 +- conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda + sha256: 1d30098909076af33a35017eed6f2953af1c769e273a0626a04722ac4acaba3c + md5: ad659d0a2b3e47e38d829aa8cad2d610 + license: LicenseRef-Public-Domain + purls: [] + size: 119135 + timestamp: 1767016325805 +- conda: https://conda.anaconda.org/conda-forge/linux-64/ukkonen-1.1.0-py312hd9148b4_0.conda + sha256: c975070ac28fe23a5bbb2b8aeca5976b06630eb2de2dc149782f74018bf07ae8 + md5: 55fd03988b1b1bc6faabbfb5b481ecd7 + depends: + - __glibc >=2.17,<3.0.a0 + - cffi + - libgcc >=14 + - libstdcxx >=14 + - python >=3.12,<3.13.0a0 + - python_abi 3.12.* *_cp312 + license: MIT + license_family: MIT + purls: + - pkg:pypi/ukkonen?source=hash-mapping + size: 14882 + timestamp: 1769438717830 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/ukkonen-1.1.0-py312h4f740d2_0.conda + sha256: 782101cc2266b2126c44771e4c03658d0c55d933f34b91523d0bdd38ad2f3e10 + md5: 9d8284ec3764a95eff0cde0e70772315 + depends: + - cffi + - libgcc >=14 + - libstdcxx >=14 + - python >=3.12,<3.13.0a0 + - python >=3.12,<3.13.0a0 *_cpython + - python_abi 3.12.* *_cp312 + license: MIT + license_family: MIT + purls: + - pkg:pypi/ukkonen?source=hash-mapping + size: 15682 + timestamp: 1769438785443 +- conda: https://conda.anaconda.org/conda-forge/noarch/virtualenv-20.36.1-pyhd8ed1ab_0.conda + sha256: fa0a21fdcd0a8e6cf64cc8cd349ed6ceb373f09854fd3c4365f0bc4586dccf9a + md5: 6b0259cea8ffa6b66b35bae0ca01c447 + depends: + - distlib >=0.3.7,<1 + - filelock >=3.20.1,<4 + - platformdirs >=3.9.1,<5 + - python >=3.10 + - typing_extensions >=4.13.2 + license: MIT + license_family: MIT + purls: + - pkg:pypi/virtualenv?source=hash-mapping + size: 4404318 + timestamp: 1768069793682 +- conda: https://conda.anaconda.org/conda-forge/linux-64/yaml-0.2.5-h280c20c_3.conda + sha256: 6d9ea2f731e284e9316d95fa61869fe7bbba33df7929f82693c121022810f4ad + md5: a77f85f77be52ff59391544bfe73390a + depends: + - libgcc >=14 + - __glibc >=2.17,<3.0.a0 + license: MIT + license_family: MIT + purls: [] + size: 85189 + timestamp: 1753484064210 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/yaml-0.2.5-h80f16a2_3.conda + sha256: 66265e943f32ce02396ad214e27cb35f5b0490b3bd4f064446390f9d67fa5d88 + md5: 032d8030e4a24fe1f72c74423a46fb88 + depends: + - libgcc >=14 + license: MIT + license_family: MIT + purls: [] + size: 88088 + timestamp: 1753484092643 +- conda: https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb78ec9c_6.conda + sha256: 68f0206ca6e98fea941e5717cec780ed2873ffabc0e1ed34428c061e2c6268c7 + md5: 4a13eeac0b5c8e5b8ab496e6c4ddd829 + depends: + - __glibc >=2.17,<3.0.a0 + - libzlib >=1.3.1,<2.0a0 + license: BSD-3-Clause + license_family: BSD + purls: [] + size: 601375 + timestamp: 1764777111296 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/zstd-1.5.7-h85ac4a6_6.conda + sha256: 569990cf12e46f9df540275146da567d9c618c1e9c7a0bc9d9cfefadaed20b75 + md5: c3655f82dcea2aa179b291e7099c1fcc + depends: + - libzlib >=1.3.1,<2.0a0 + license: BSD-3-Clause + license_family: BSD + purls: [] + size: 614429 + timestamp: 1764777145593 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..74ec5b7 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,98 @@ +[project] +name = "handmotion" +description = "handmotion" +version = "0.0.1" +readme = "README.md" +keywords = [] +authors = [{name = "Julia Jia"}] +requires-python = ">=3.12,<3.13" + +[tool.pixi.workspace] +channels = ["conda-forge"] +platforms = ["linux-64", "linux-aarch64"] + +[tool.pixi.dependencies] + +[tool.pixi.pypi-dependencies] +handmotion = { path = ".", editable = true } +joblib = "*" +mediapipe = "*" +numpy = "*" +pillow = "*" +scikit-learn = "*" + +[tool.pixi.environments] +default = { solve-group = "default" } +dev = { features = ["dev"], solve-group = "default"} + +[tool.pixi.feature.dev.activation] +# Clean PYTHONPATH to remove system ROS Python packages +# Keep only conda environment paths, once we have install/setup.bash, we may need following fix +env = { PYTHONPATH = "${CONDA_PREFIX}/lib/python/site-packages:${CONDA_PREFIX}/lib/python3.12/site-packages" } + +[tool.pixi.feature.dev.tasks] +fmt = { cmd = "ruff format ." } +lint = { cmd = "ruff check . --fix" } +types = { cmd = "ty check" } +test = { cmd = "pytest" } +pre-commit = { cmd="pre-commit run --all-files" } +pre-commit-install = { cmd="pre-commit install" } +all = { depends-on = ["fmt", "lint", "types", "test"] } + +[tool.pixi.feature.dev.dependencies] +pytest = ">=8.3" +pytest-timeout = ">=2.2" +ruff = ">=0.11" +pre-commit = ">=4.2" + +[tool.pixi.feature.dev.pypi-dependencies] +ty = "*" + +[tool.ruff] +line-length = 100 + +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "F", # pyflakes + "I", # isort + "W", # pycodestyle warnings + "B", # bugbear + "N", # pep8-naming + "PT", # pytest-style + "RUF", # Ruff-specific rules +] +ignore = [ + "N806", # Non-lowercase variable in function + "PLR0911", # Too many returns + "PLR0912", # Too many branches + "PLR0913", # Too many arguments to function call + "PLR0914", # Too many locals + "PLR0915", # Too many statements + "PLR1702", # Too many nested-blocks +] + +[tool.ruff.lint.per-file-ignores] +"__init__.py" = ["F401", "F403"] + +[tool.ruff.lint.pydocstyle] +convention = "numpy" + +[tool.pytest.ini_options] +testpaths = ["tests", "handmotion"] +addopts = "--doctest-modules --timeout=300 -W ignore::pytest.PytestUnraisableExceptionWarning" +doctest_optionflags = "NORMALIZE_WHITESPACE" +timeout = 300 +markers = [ + "slow: marks tests as slow (deselect with '-m \"not slow\"')", +] +filterwarnings = [ + "ignore::pytest.PytestUnraisableExceptionWarning", +] + +[tool.hatchling.build.targets.wheel] +packages = ["handmotion"] + +[build-system] +build-backend = "hatchling.build" +requires = ["hatchling"] diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..8bddb9d --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,92 @@ +# Copyright (C) 2026 Julia Jia +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Shared fixtures and utilities for tests.""" + +from unittest.mock import MagicMock + +import numpy as np +import pytest +from PIL import Image + + +@pytest.fixture +def sample_landmarks(): + """Create sample landmarks array (21, 3).""" + return np.random.rand(21, 3) + + +@pytest.fixture +def sample_finger_states(): + """Create sample finger states array (5,).""" + return np.array([True, False, True, False, True]) + + +@pytest.fixture +def sample_hand_features(sample_landmarks, sample_finger_states): + """Create sample hand features dictionary.""" + return { + "landmarks": sample_landmarks, + "finger_states": sample_finger_states, + "handedness": "Right", + } + + +@pytest.fixture +def sample_image(tmp_path): + """Create a sample test image file.""" + image_path = tmp_path / "test.jpg" + image = Image.new("RGB", (100, 100), color="red") + image.save(image_path) + return image_path + + +@pytest.fixture +def mock_feature_extractor(sample_hand_features): + """Create a mock feature extractor.""" + mock_extractor = MagicMock() + mock_extractor.extract.return_value = sample_hand_features + return mock_extractor + + +@pytest.fixture +def mock_classifier(): + """Create a mock classifier with default setup.""" + mock_clf = MagicMock() + mock_clf._prepare_features.return_value = np.random.rand(1, 68) + mock_clf.predict.return_value = np.array(["rock"]) + mock_clf.predict_proba.return_value = np.array([[0.05, 0.9, 0.05]]) + mock_clf.label_encoder.classes_ = np.array(["paper", "rock", "scissors"]) + return mock_clf + + +@pytest.fixture(autouse=True) +def cleanup_mediapipe(): + """Auto-cleanup fixture to ensure MediaPipe resources are released.""" + yield + # Force garbage collection to trigger cleanup + import gc + + gc.collect() + + +def create_test_npz(tmp_path, n_samples=10): + """Helper to create test npz file.""" + landmarks = np.random.rand(n_samples, 21, 3) + finger_states = np.random.rand(n_samples, 5) + labels = (["rock", "paper", "scissors"] * ((n_samples // 3) + 1))[:n_samples] + + data_path = tmp_path / "test_data.npz" + np.savez(data_path, landmarks=landmarks, finger_states=finger_states, labels=labels) + return data_path, landmarks, finger_states, labels diff --git a/tests/data/rps/.gitkeep b/tests/data/rps/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/data/rps/paper/103.jpg b/tests/data/rps/paper/103.jpg new file mode 100644 index 0000000..7e6d069 Binary files /dev/null and b/tests/data/rps/paper/103.jpg differ diff --git a/tests/data/rps/rock/133.jpg b/tests/data/rps/rock/133.jpg new file mode 100644 index 0000000..a803e1f Binary files /dev/null and b/tests/data/rps/rock/133.jpg differ diff --git a/tests/data/rps/scissors/100.jpg b/tests/data/rps/scissors/100.jpg new file mode 100644 index 0000000..1820842 Binary files /dev/null and b/tests/data/rps/scissors/100.jpg differ diff --git a/tests/test_feature_extractor.py b/tests/test_feature_extractor.py new file mode 100644 index 0000000..af1b9e4 --- /dev/null +++ b/tests/test_feature_extractor.py @@ -0,0 +1,193 @@ +# Copyright (C) 2026 Julia Jia +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Unit tests for feature extractor.""" + +from unittest.mock import MagicMock, patch + +import numpy as np +from PIL import Image + +from handmotion.data.feature_extractor import FeatureExtractor, HandFeatures + + +class TestFeatureExtractor: + """Test cases for FeatureExtractor.""" + + def test_init(self): + """Test FeatureExtractor initialization.""" + extractor = FeatureExtractor() + assert extractor.hand_landmarker is not None + + def test_extract_no_hand_detected(self): + """Test extract returns None when no hand is detected.""" + extractor = FeatureExtractor() + # Create a mock result with no hand landmarks + mock_result = MagicMock() + mock_result.hand_landmarks = None + mock_result.handedness = None + + with patch.object(extractor.hand_landmarker, "detect", return_value=mock_result): + image = Image.new("RGB", (100, 100)) + result = extractor.extract(image) + assert result is None + + def test_extract_with_hand(self): + """Test extract returns features when hand is detected.""" + extractor = FeatureExtractor() + + # Create mock landmarks for new API + mock_landmark = MagicMock() + mock_landmark.x = 0.5 + mock_landmark.y = 0.5 + mock_landmark.z = 0.0 + + # New API: hand_landmarks is a list of landmarks directly + mock_hand_landmarks = [mock_landmark] * 21 + + # New API: handedness is List[List[Category]] - first list is per hand + mock_category = MagicMock() + mock_category.category_name = "Right" + # First hand's categories list + mock_hand_categories = [mock_category] + # List of hands (each hand has a list of categories) + mock_handedness = [mock_hand_categories] + + mock_result = MagicMock() + mock_result.hand_landmarks = [mock_hand_landmarks] + mock_result.handedness = mock_handedness + + with patch.object(extractor.hand_landmarker, "detect", return_value=mock_result): + image = Image.new("RGB", (100, 100)) + result = extractor.extract(image) + + assert result is not None + assert "landmarks" in result + assert "finger_states" in result + assert "handedness" in result + assert result["handedness"] == "Right" + assert result["landmarks"].shape == (21, 3) + assert result["finger_states"].shape == (5,) + + def test_extract_without_handedness(self): + """Test extract handles missing handedness.""" + extractor = FeatureExtractor() + + mock_landmark = MagicMock() + mock_landmark.x = 0.5 + mock_landmark.y = 0.5 + mock_landmark.z = 0.0 + + mock_hand_landmarks = [mock_landmark] * 21 + + mock_result = MagicMock() + mock_result.hand_landmarks = [mock_hand_landmarks] + mock_result.handedness = None + + with patch.object(extractor.hand_landmarker, "detect", return_value=mock_result): + image = Image.new("RGB", (100, 100)) + result = extractor.extract(image) + + assert result is not None + assert result["handedness"] is None + + def test_normalize(self): + """Test landmark normalization.""" + extractor = FeatureExtractor() + + # Create test landmarks (21 points, 3D) + landmarks = np.random.rand(21, 3) + # Set wrist at origin for easier testing + landmarks[0] = [0.5, 0.5, 0.0] + + normalized = extractor.normalize(landmarks) + + # Wrist should be at origin after normalization + assert np.allclose(normalized[0], [0, 0, 0], atol=1e-6) + + # Check that landmarks are scaled + assert normalized.shape == (21, 3) + + def test_normalize_zero_scale(self): + """Test normalize handles zero scale edge case.""" + extractor = FeatureExtractor() + + # Create landmarks where middle finger MCP is at wrist (zero scale) + landmarks = np.zeros((21, 3)) + landmarks[0] = [0.5, 0.5, 0.0] # Wrist + landmarks[9] = [0.5, 0.5, 0.0] # Middle finger MCP at same position + + normalized = extractor.normalize(landmarks) + + # Should not crash, but scale won't be applied + assert normalized.shape == (21, 3) + + def test_get_finger_states(self): + """Test finger state detection.""" + extractor = FeatureExtractor() + + # Create normalized landmarks with extended fingers + landmarks = np.zeros((21, 3)) + landmarks[0] = [0, 0, 0] # Wrist at origin + + # Set finger tips above MCPs (extended) + landmarks[8, 1] = 0.1 # Index tip (y < mcp y means extended) + landmarks[5, 1] = 0.2 # Index MCP + landmarks[12, 1] = 0.1 # Middle tip + landmarks[9, 1] = 0.2 # Middle MCP + landmarks[16, 1] = 0.1 # Ring tip + landmarks[13, 1] = 0.2 # Ring MCP + landmarks[20, 1] = 0.1 # Pinky tip + landmarks[17, 1] = 0.2 # Pinky MCP + + # Thumb: tip further from wrist than IP + landmarks[4] = [0.3, 0.0, 0.0] # Thumb tip + landmarks[3] = [0.2, 0.0, 0.0] # Thumb IP + + finger_states = extractor._get_finger_states(landmarks) + + assert finger_states.shape == (5,) + assert finger_states.dtype == bool + # All fingers should be extended in this setup + assert np.all(finger_states) + + def test_format_features_none(self): + """Test format_features with None input.""" + result = FeatureExtractor.format_features(None) + assert result == "No hand detected" + + def test_format_features(self): + """Test format_features with valid features.""" + features: HandFeatures = { + "landmarks": np.random.rand(21, 3), + "finger_states": np.array([True, True, False, False, True]), + "handedness": "Right", + } + + result = FeatureExtractor.format_features(features) + assert "Right" in result + assert "thumb" in result + assert "extended" in result + assert "curled" in result + + def test_format_features_no_handedness(self): + """Test format_features with None handedness.""" + features: HandFeatures = { + "landmarks": np.random.rand(21, 3), + "finger_states": np.array([True, False, False, False, False]), + "handedness": None, + } + + result = FeatureExtractor.format_features(features) + assert "unknown" in result diff --git a/tests/test_model.py b/tests/test_model.py new file mode 100644 index 0000000..8f1592b --- /dev/null +++ b/tests/test_model.py @@ -0,0 +1,276 @@ +# Copyright (C) 2026 Julia Jia +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Unit tests for hand gesture classifier model.""" + +import numpy as np +import pytest +from sklearn.ensemble import RandomForestClassifier +from sklearn.linear_model import LogisticRegression + +from handmotion.model import HandGestureClassifier + + +class TestHandGestureClassifier: + """Test cases for HandGestureClassifier.""" + + def test_init_default(self): + """Test initialization with default classifier.""" + classifier = HandGestureClassifier() + assert isinstance(classifier.classifier, RandomForestClassifier) + assert classifier.classifier.random_state == 42 + assert not classifier.is_trained + + def test_init_custom_classifier(self): + """Test initialization with custom classifier.""" + custom_clf = LogisticRegression() + classifier = HandGestureClassifier(classifier=custom_clf) + assert classifier.classifier is custom_clf + assert not classifier.is_trained + + def test_prepare_features_3d_landmarks(self): + """Test _prepare_features with 3D landmarks array.""" + classifier = HandGestureClassifier() + landmarks = np.random.rand(5, 21, 3) + finger_states = np.random.rand(5, 5) + + features = classifier._prepare_features(landmarks, finger_states) + + assert features.shape == (5, 68) + assert features.ndim == 2 + + def test_prepare_features_2d_landmarks(self): + """Test _prepare_features with 2D landmarks array.""" + classifier = HandGestureClassifier() + landmarks = np.random.rand(21, 3) + finger_states = np.array([True, False, True, False, True]) + + features = classifier._prepare_features(landmarks, finger_states) + + assert features.shape == (1, 68) + assert features.ndim == 2 + + def test_prepare_features_1d_finger_states(self): + """Test _prepare_features with 1D finger_states (single sample).""" + classifier = HandGestureClassifier() + landmarks = np.random.rand(21, 3) # Single sample, 2D + finger_states = np.array([True, False, True, False, True]) # 1D + + features = classifier._prepare_features(landmarks, finger_states) + + assert features.shape == (1, 68) + + def test_prepare_features_batch_with_2d_finger_states(self): + """Test _prepare_features with 3D landmarks and 2D finger_states (batch).""" + classifier = HandGestureClassifier() + landmarks = np.random.rand(3, 21, 3) # Batch of 3 + finger_states = np.random.rand(3, 5) # Batch of 3, matching size + + features = classifier._prepare_features(landmarks, finger_states) + + assert features.shape == (3, 68) + + def test_load_data(self, tmp_path): + """Test load_data loads npz file correctly.""" + # Use helper function from conftest + from conftest import create_test_npz + + classifier = HandGestureClassifier() + data_path, _landmarks, _finger_states, labels = create_test_npz(tmp_path, n_samples=10) + + features, loaded_labels = classifier.load_data(data_path) + + assert features.shape == (10, 68) + assert len(loaded_labels) == 10 + assert np.array_equal(loaded_labels, labels) + + def test_load_data_nonexistent(self): + """Test load_data raises error for nonexistent file.""" + classifier = HandGestureClassifier() + with pytest.raises(FileNotFoundError, match="Data file not found"): + classifier.load_data("/nonexistent/path/data.npz") + + def test_train(self): + """Test train method trains classifier correctly.""" + classifier = HandGestureClassifier() + features = np.random.rand(100, 68) + labels = np.array(["rock", "paper", "scissors"] * 33 + ["rock"]) + + X_train, X_test, y_train, y_test = classifier.train(features, labels, test_size=0.2) + + assert classifier.is_trained + assert len(X_train) == 80 + assert len(X_test) == 20 + assert len(y_train) == 80 + assert len(y_test) == 20 + assert X_train.shape[1] == 68 + assert X_test.shape[1] == 68 + + def test_train_custom_test_size(self): + """Test train with custom test_size.""" + classifier = HandGestureClassifier() + features = np.random.rand(100, 68) + labels = np.array(["rock", "paper", "scissors"] * 33 + ["rock"]) + + X_train, X_test, _y_train, _y_test = classifier.train(features, labels, test_size=0.3) + + assert len(X_train) == 70 + assert len(X_test) == 30 + + def test_predict_not_trained(self): + """Test predict raises error when model not trained.""" + classifier = HandGestureClassifier() + features = np.random.rand(68) + + with pytest.raises(ValueError, match="Classifier must be trained before prediction"): + classifier.predict(features) + + def test_predict_1d_features(self): + """Test predict with 1D feature array.""" + classifier = HandGestureClassifier() + features = np.random.rand(100, 68) + labels = np.array(["rock", "paper", "scissors"] * 33 + ["rock"]) + + classifier.train(features, labels) + + single_feature = np.random.rand(68) + predictions = classifier.predict(single_feature) + + assert isinstance(predictions, np.ndarray) + assert len(predictions) == 1 + assert predictions[0] in ["rock", "paper", "scissors"] + + def test_predict_2d_features(self): + """Test predict with 2D feature array.""" + classifier = HandGestureClassifier() + features = np.random.rand(100, 68) + labels = np.array(["rock", "paper", "scissors"] * 33 + ["rock"]) + + classifier.train(features, labels) + + test_features = np.random.rand(5, 68) + predictions = classifier.predict(test_features) + + assert len(predictions) == 5 + assert all(pred in ["rock", "paper", "scissors"] for pred in predictions) + + def test_predict_proba_not_trained(self): + """Test predict_proba raises error when model not trained.""" + classifier = HandGestureClassifier() + features = np.random.rand(68) + + with pytest.raises(ValueError, match="Classifier must be trained before prediction"): + classifier.predict_proba(features) + + def test_predict_proba(self): + """Test predict_proba returns probabilities.""" + classifier = HandGestureClassifier() + features = np.random.rand(100, 68) + labels = np.array(["rock", "paper", "scissors"] * 33 + ["rock"]) + + classifier.train(features, labels) + + test_features = np.random.rand(3, 68) + probabilities = classifier.predict_proba(test_features) + + assert probabilities.shape == (3, 3) + assert np.allclose(probabilities.sum(axis=1), 1.0) + assert np.all(probabilities >= 0) + assert np.all(probabilities <= 1) + + def test_predict_proba_1d_features(self): + """Test predict_proba with 1D feature array.""" + classifier = HandGestureClassifier() + features = np.random.rand(100, 68) + labels = np.array(["rock", "paper", "scissors"] * 33 + ["rock"]) + + classifier.train(features, labels) + + single_feature = np.random.rand(68) + probabilities = classifier.predict_proba(single_feature) + + assert probabilities.shape == (1, 3) + assert np.allclose(probabilities.sum(), 1.0) + + def test_save_not_trained(self, tmp_path): + """Test save raises error when model not trained.""" + classifier = HandGestureClassifier() + model_path = tmp_path / "model.joblib" + + with pytest.raises(ValueError, match="Cannot save untrained model"): + classifier.save(model_path) + + def test_save_load(self, tmp_path): + """Test save and load model.""" + classifier = HandGestureClassifier() + features = np.random.rand(100, 68) + labels = np.array(["rock", "paper", "scissors"] * 33 + ["rock"]) + + classifier.train(features, labels) + model_path = tmp_path / "model.joblib" + + classifier.save(model_path) + assert model_path.exists() + + # Create new classifier and load + new_classifier = HandGestureClassifier() + new_classifier.load(model_path) + + assert new_classifier.is_trained + assert isinstance(new_classifier.classifier, RandomForestClassifier) + + # Test that loaded model can predict + test_features = np.random.rand(5, 68) + predictions = new_classifier.predict(test_features) + assert len(predictions) == 5 + + def test_save_creates_parent_dirs(self, tmp_path): + """Test save creates parent directories if needed.""" + classifier = HandGestureClassifier() + features = np.random.rand(100, 68) + labels = np.array(["rock", "paper", "scissors"] * 33 + ["rock"]) + + classifier.train(features, labels) + model_path = tmp_path / "models" / "subdir" / "model.joblib" + + classifier.save(model_path) + assert model_path.exists() + + def test_load_nonexistent(self): + """Test load raises error for nonexistent file.""" + classifier = HandGestureClassifier() + with pytest.raises(FileNotFoundError, match="Model file not found"): + classifier.load("/nonexistent/path/model.joblib") + + def test_predict_after_load(self, tmp_path): + """Test that predictions are consistent after save/load.""" + classifier = HandGestureClassifier() + features = np.random.rand(100, 68) + labels = np.array(["rock", "paper", "scissors"] * 33 + ["rock"]) + + classifier.train(features, labels) + model_path = tmp_path / "model.joblib" + classifier.save(model_path) + + # Get predictions before load + test_features = np.random.rand(3, 68) + predictions_before = classifier.predict(test_features) + + # Load and get predictions + new_classifier = HandGestureClassifier() + new_classifier.load(model_path) + predictions_after = new_classifier.predict(test_features) + + # Predictions should be the same + assert np.array_equal(predictions_before, predictions_after) diff --git a/tests/test_predict.py b/tests/test_predict.py new file mode 100644 index 0000000..177d7f4 --- /dev/null +++ b/tests/test_predict.py @@ -0,0 +1,70 @@ +# Copyright (C) 2026 Julia Jia +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Unit tests for prediction script.""" + +from unittest.mock import MagicMock + +import numpy as np + +from handmotion.predict import predict_image + + +class TestPredictImage: + """Test cases for predict_image function.""" + + def test_predict_image_success(self, sample_image, mock_feature_extractor, mock_classifier): + """Test successful prediction.""" + prediction, confidence = predict_image( + mock_classifier, mock_feature_extractor, sample_image + ) + + assert prediction == "rock" + assert "rock" in confidence + assert confidence["rock"] == 0.9 + mock_feature_extractor.extract.assert_called_once() + + def test_predict_image_no_hand_detected(self, sample_image): + """Test prediction when no hand is detected.""" + + mock_extractor = MagicMock() + mock_extractor.extract.return_value = None + mock_classifier = MagicMock() + + prediction, confidence = predict_image(mock_classifier, mock_extractor, sample_image) + + assert prediction is None + assert confidence is None + mock_extractor.extract.assert_called_once() + mock_classifier.predict.assert_not_called() + + def test_predict_image_confidence_dict(self, sample_image, mock_feature_extractor): + """Test that confidence dict contains all classes.""" + + mock_classifier = MagicMock() + mock_classifier._prepare_features.return_value = np.random.rand(1, 68) + mock_classifier.predict.return_value = np.array(["paper"]) + mock_classifier.predict_proba.return_value = np.array([[0.1, 0.8, 0.1]]) + mock_classifier.label_encoder.classes_ = np.array(["rock", "paper", "scissors"]) + + prediction, confidence = predict_image( + mock_classifier, mock_feature_extractor, sample_image + ) + + assert prediction == "paper" + assert len(confidence) == 3 + assert "rock" in confidence + assert "paper" in confidence + assert "scissors" in confidence + assert confidence["paper"] == 0.8 diff --git a/tests/test_processor.py b/tests/test_processor.py new file mode 100644 index 0000000..bad57be --- /dev/null +++ b/tests/test_processor.py @@ -0,0 +1,252 @@ +# Copyright (C) 2026 Julia Jia +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Unit tests for data processor.""" + +from pathlib import Path +from unittest.mock import MagicMock, patch + +import numpy as np +import pytest +from PIL import Image + +from handmotion.data.processor import DataProcessor + + +class TestDataProcessor: + """Test cases for DataProcessor.""" + + def test_init(self): + """Test DataProcessor initialization.""" + # Mock FeatureExtractor to avoid slow MediaPipe initialization + with patch("handmotion.data.processor.FeatureExtractor"): + processor = DataProcessor() + assert processor.extractor is not None + + def test_process_folder_nonexistent(self): + """Test process_folder raises error for nonexistent folder.""" + with patch("handmotion.data.processor.FeatureExtractor"): + processor = DataProcessor() + with pytest.raises(ValueError, match="Folder does not exist"): + processor.process_folder("/nonexistent/path", "test") + + def test_process_folder_empty(self, tmp_path): + """Test process_folder returns empty list for folder with no images.""" + with patch("handmotion.data.processor.FeatureExtractor"): + processor = DataProcessor() + result = processor.process_folder(tmp_path, "test") + assert result == [] + + def test_process_folder_with_images(self, tmp_path): + """Test process_folder processes images successfully.""" + with patch("handmotion.data.processor.FeatureExtractor"): + processor = DataProcessor() + + # Create test images + image1 = Image.new("RGB", (100, 100), color="red") + image2 = Image.new("RGB", (100, 100), color="blue") + image1.save(tmp_path / "test1.jpg") + image2.save(tmp_path / "test2.png") + + # Mock the extractor to return features + mock_features = { + "landmarks": np.random.rand(21, 3), + "finger_states": np.array([True, True, False, False, True]), + "handedness": "Right", + } + + with patch.object(processor.extractor, "extract", return_value=mock_features): + result = processor.process_folder(tmp_path, "rock") + + assert len(result) == 2 + for features, label in result: + assert label == "rock" + assert features == mock_features + + def test_process_folder_skips_no_hand(self, tmp_path): + """Test process_folder skips images with no hand detected.""" + mock_extractor = MagicMock() + mock_extractor.extract.return_value = None + + with patch("handmotion.data.processor.FeatureExtractor", return_value=mock_extractor): + processor = DataProcessor() + + image = Image.new("RGB", (100, 100)) + image.save(tmp_path / "test.jpg") + + result = processor.process_folder(tmp_path, "paper") + assert result == [] + + def test_process_folder_handles_errors(self, tmp_path): + """Test process_folder handles processing errors gracefully.""" + # Create a mock extractor instance + mock_extractor = MagicMock() + mock_extractor.extract.side_effect = Exception("Test error") + + # Mock FeatureExtractor class to return our mock instance + with patch("handmotion.data.processor.FeatureExtractor", return_value=mock_extractor): + processor = DataProcessor() + + image = Image.new("RGB", (100, 100)) + image.save(tmp_path / "test.jpg") + + result = processor.process_folder(tmp_path, "scissors") + assert result == [] + + def test_save(self, tmp_path): + """Test save writes npz file correctly.""" + # Mock FeatureExtractor to avoid MediaPipe initialization + with patch("handmotion.data.processor.FeatureExtractor"): + processor = DataProcessor() + + # Create test data + features1 = { + "landmarks": np.array([[0.1, 0.2, 0.3]] * 21), + "finger_states": np.array([True, False, True, False, True]), + "handedness": "Right", + } + features2 = { + "landmarks": np.array([[0.4, 0.5, 0.6]] * 21), + "finger_states": np.array([False, True, False, True, False]), + "handedness": "Left", + } + + data = [(features1, "rock"), (features2, "paper")] + output_path = tmp_path / "output.npz" + + processor.save(data, output_path) + + assert output_path.exists() + + # Load and verify + loaded = np.load(output_path, allow_pickle=True) + assert "landmarks" in loaded + assert "finger_states" in loaded + assert "handedness" in loaded + assert "labels" in loaded + + assert loaded["landmarks"].shape == (2, 21, 3) + assert loaded["finger_states"].shape == (2, 5) + assert len(loaded["handedness"]) == 2 + assert len(loaded["labels"]) == 2 + assert loaded["labels"][0] == "rock" + assert loaded["labels"][1] == "paper" + + def test_save_empty_data(self): + """Test save raises error for empty data.""" + # Mock FeatureExtractor to avoid MediaPipe initialization + with patch("handmotion.data.processor.FeatureExtractor"): + processor = DataProcessor() + with pytest.raises(ValueError, match="Cannot save empty data"): + processor.save([], "output.npz") + + def test_save_creates_parent_dirs(self, tmp_path): + """Test save creates parent directories if needed.""" + # Mock FeatureExtractor to avoid MediaPipe initialization + with patch("handmotion.data.processor.FeatureExtractor"): + processor = DataProcessor() + + features = { + "landmarks": np.random.rand(21, 3), + "finger_states": np.array([True] * 5), + "handedness": "Right", + } + data = [(features, "test")] + + # Create nested path + output_path = tmp_path / "nested" / "dir" / "output.npz" + processor.save(data, output_path) + + assert output_path.exists() + assert output_path.parent.exists() + + def test_process_folder_multiple_formats(self, tmp_path): + """Test process_folder handles multiple image formats.""" + with patch("handmotion.data.processor.FeatureExtractor"): + processor = DataProcessor() + + # Create images in different formats + Image.new("RGB", (50, 50)).save(tmp_path / "test1.jpg") + Image.new("RGB", (50, 50)).save(tmp_path / "test2.png") + Image.new("RGB", (50, 50)).save(tmp_path / "test3.bmp") + # Create a non-image file (should be ignored) + (tmp_path / "test.txt").write_text("not an image") + + mock_features = { + "landmarks": np.random.rand(21, 3), + "finger_states": np.array([True] * 5), + "handedness": "Right", + } + + with patch.object(processor.extractor, "extract", return_value=mock_features): + result = processor.process_folder(tmp_path, "rock") + + # Should process 3 images, ignore txt file + assert len(result) == 3 + + @pytest.mark.slow + def test_process_folder_with_real_images(self): + """Test process_folder with actual RPS images from test data folder.""" + processor = DataProcessor() + + # Use test images from tests/data/rps + test_data_dir = Path(__file__).parent / "data" / "rps" + + # Test with rock images + rock_dir = test_data_dir / "rock" + if rock_dir.exists() and any(rock_dir.glob("*.jpg")): + # Use available test images + available_images = list(rock_dir.glob("*.jpg")) + + if available_images: + result = processor.process_folder(rock_dir, "rock") + + # Should process at least some images (may skip if no hand detected) + assert isinstance(result, list) + # Verify structure of results + for features, label in result: + assert label == "rock" + assert "landmarks" in features + assert "finger_states" in features + assert "handedness" in features + assert features["landmarks"].shape == (21, 3) + assert features["finger_states"].shape == (5,) + + @pytest.mark.slow + def test_process_folder_multiple_labels(self): + """Test processing multiple folders with different labels.""" + processor = DataProcessor() + + # Use test images from tests/data/rps + test_data_dir = Path(__file__).parent / "data" / "rps" + + all_results = [] + labels_to_test = ["rock", "paper", "scissors"] + + for label in labels_to_test: + label_dir = test_data_dir / label + if label_dir.exists() and any(label_dir.glob("*.jpg")): + result = processor.process_folder(label_dir, label) + all_results.extend(result) + + # If we got any results, verify they have correct labels + if all_results: + labels_found = {label for _, label in all_results} + assert labels_found.issubset(set(labels_to_test)) + # Verify structure of results + for features, _label in all_results: + assert "landmarks" in features + assert "finger_states" in features + assert features["landmarks"].shape == (21, 3) + assert features["finger_states"].shape == (5,)