From 710a1f7de30f4cacd17f233ca1d067c96bc9414b Mon Sep 17 00:00:00 2001 From: PhatPhuckDave Date: Fri, 26 Sep 2025 13:10:47 +0200 Subject: [PATCH] Maybe implement a feature tracker --- croppa/main.py | 261 ++++++++++++++++++++++++++++++++++++++++++++++++- uv.lock | 57 +++++++++++ 2 files changed, 314 insertions(+), 4 deletions(-) diff --git a/croppa/main.py b/croppa/main.py index 8279840..6cf25c4 100644 --- a/croppa/main.py +++ b/croppa/main.py @@ -4,7 +4,7 @@ import cv2 import argparse import numpy as np from pathlib import Path -from typing import List +from typing import List, Optional, Tuple, Dict, Any import time import re import threading @@ -25,6 +25,173 @@ def load_image_utf8(image_path): except Exception as e: raise ValueError(f"Could not load image file: {image_path} - {e}") + +class FeatureTracker: + """Semi-automatic feature tracking with SIFT/SURF/ORB support and full state serialization""" + + def __init__(self): + # Feature detection parameters + self.detector_type = 'SIFT' # 'SIFT', 'SURF', 'ORB' + self.max_features = 1000 + self.match_threshold = 0.7 + + # Tracking state + self.features = {} # {frame_number: {'keypoints': [...], 'descriptors': [...], 'positions': [...]}} + self.tracking_enabled = False + self.auto_tracking = False + + # Initialize detectors + self._init_detectors() + + def _init_detectors(self): + """Initialize feature detectors based on type""" + try: + if self.detector_type == 'SIFT': + self.detector = cv2.SIFT_create(nfeatures=self.max_features) + self.matcher = cv2.BFMatcher() + elif self.detector_type == 'SURF': + self.detector = cv2.xfeatures2d.SURF_create(hessianThreshold=400) + self.matcher = cv2.BFMatcher() + elif self.detector_type == 'ORB': + self.detector = cv2.ORB_create(nfeatures=self.max_features) + self.matcher = cv2.BFMatcher(cv2.NORM_HAMMING, crossCheck=True) + else: + raise ValueError(f"Unknown detector type: {self.detector_type}") + except Exception as e: + print(f"Warning: Could not initialize {self.detector_type} detector: {e}") + # Fallback to ORB + self.detector_type = 'ORB' + self.detector = cv2.ORB_create(nfeatures=self.max_features) + self.matcher = cv2.BFMatcher(cv2.NORM_HAMMING, crossCheck=True) + + def set_detector_type(self, detector_type: str): + """Change detector type and reinitialize""" + if detector_type in ['SIFT', 'SURF', 'ORB']: + self.detector_type = detector_type + self._init_detectors() + print(f"Switched to {detector_type} detector") + else: + print(f"Invalid detector type: {detector_type}") + + def extract_features(self, frame: np.ndarray, frame_number: int) -> bool: + """Extract features from a frame and store them""" + try: + # Convert to grayscale if needed + if len(frame.shape) == 3: + gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) + else: + gray = frame + + # Extract keypoints and descriptors + keypoints, descriptors = self.detector.detectAndCompute(gray, None) + + if keypoints is None or descriptors is None: + return False + + # Store features + self.features[frame_number] = { + 'keypoints': keypoints, + 'descriptors': descriptors, + 'positions': [(int(kp.pt[0]), int(kp.pt[1])) for kp in keypoints] + } + + print(f"Extracted {len(keypoints)} features from frame {frame_number}") + return True + + except Exception as e: + print(f"Error extracting features from frame {frame_number}: {e}") + return False + + + + def get_tracking_position(self, frame_number: int) -> Optional[Tuple[float, float]]: + """Get the average tracking position for a frame""" + if frame_number not in self.features or not self.features[frame_number]['positions']: + return None + + positions = self.features[frame_number]['positions'] + if not positions: + return None + + avg_x = sum(pos[0] for pos in positions) / len(positions) + avg_y = sum(pos[1] for pos in positions) / len(positions) + return (avg_x, avg_y) + + + def clear_features(self): + """Clear all stored features""" + self.features.clear() + print("All features cleared") + + def get_feature_count(self, frame_number: int) -> int: + """Get number of features for a frame""" + if frame_number in self.features: + return len(self.features[frame_number]['positions']) + return 0 + + def serialize_features(self) -> Dict[str, Any]: + """Serialize features for state saving""" + serialized = {} + + for frame_num, frame_data in self.features.items(): + frame_key = str(frame_num) + serialized[frame_key] = { + 'positions': frame_data['positions'], + 'keypoints': None, # Keypoints are not serialized (too large) + 'descriptors': None # Descriptors are not serialized (too large) + } + + return serialized + + def deserialize_features(self, serialized_data: Dict[str, Any]): + """Deserialize features from state loading""" + self.features.clear() + + for frame_key, frame_data in serialized_data.items(): + frame_num = int(frame_key) + self.features[frame_num] = { + 'positions': frame_data['positions'], + 'keypoints': None, + 'descriptors': None + } + + print(f"Deserialized features for {len(self.features)} frames") + + def get_state_dict(self) -> Dict[str, Any]: + """Get complete state for serialization""" + return { + 'detector_type': self.detector_type, + 'max_features': self.max_features, + 'match_threshold': self.match_threshold, + 'tracking_enabled': self.tracking_enabled, + 'auto_tracking': self.auto_tracking, + 'features': self.serialize_features() + } + + def load_state_dict(self, state_dict: Dict[str, Any]): + """Load complete state from serialization""" + if 'detector_type' in state_dict: + self.detector_type = state_dict['detector_type'] + self._init_detectors() + + if 'max_features' in state_dict: + self.max_features = state_dict['max_features'] + + if 'match_threshold' in state_dict: + self.match_threshold = state_dict['match_threshold'] + + if 'tracking_enabled' in state_dict: + self.tracking_enabled = state_dict['tracking_enabled'] + + if 'auto_tracking' in state_dict: + self.auto_tracking = state_dict['auto_tracking'] + + if 'features' in state_dict: + self.deserialize_features(state_dict['features']) + + print("Feature tracker state loaded") + + class Cv2BufferedCap: """Buffered wrapper around cv2.VideoCapture that handles frame loading, seeking, and caching correctly""" @@ -595,6 +762,9 @@ class VideoEditor: self.tracking_points = {} # {frame_number: [(x, y), ...]} in original frame coords self.tracking_enabled = False + # Feature tracking system + self.feature_tracker = FeatureTracker() + # Project view mode self.project_view_mode = False self.project_view = None @@ -639,7 +809,8 @@ class VideoEditor: 'seek_multiplier': getattr(self, 'seek_multiplier', 1.0), 'is_playing': getattr(self, 'is_playing', False), 'tracking_enabled': self.tracking_enabled, - 'tracking_points': {str(k): v for k, v in self.tracking_points.items()} + 'tracking_points': {str(k): v for k, v in self.tracking_points.items()}, + 'feature_tracker': self.feature_tracker.get_state_dict() } with open(state_file, 'w') as f: @@ -721,6 +892,11 @@ class VideoEditor: if 'tracking_points' in state and isinstance(state['tracking_points'], dict): self.tracking_points = {int(k): v for k, v in state['tracking_points'].items()} print(f"Loaded tracking_points: {sum(len(v) for v in self.tracking_points.values())} points") + + # Load feature tracker state + if 'feature_tracker' in state: + self.feature_tracker.load_state_dict(state['feature_tracker']) + print(f"Loaded feature tracker state") # Validate cut markers against current video length if self.cut_start_frame is not None and self.cut_start_frame >= self.total_frames: @@ -1054,6 +1230,18 @@ class VideoEditor: """Seek to specific frame""" self.current_frame = max(0, min(frame_number, self.total_frames - 1)) self.load_current_frame() + + # Auto-extract features if feature tracking is enabled and auto-tracking is on + if (not self.is_image_mode and + self.feature_tracker.tracking_enabled and + self.feature_tracker.auto_tracking and + self.current_display_frame is not None): + + # Only extract if we don't already have features for this frame + if self.current_frame not in self.feature_tracker.features: + # Extract features from the original frame (before transformations) + # This ensures features are in the original coordinate system + self.feature_tracker.extract_features(self.current_display_frame, self.current_frame) def jump_to_previous_marker(self): """Jump to the previous tracking marker (frame with tracking points).""" @@ -1270,6 +1458,14 @@ class VideoEditor: def _get_interpolated_tracking_position(self, frame_number): """Linear interpolation in ROTATED frame coords. Returns (rx, ry) or None.""" + # First try feature tracking if enabled + if self.feature_tracker.tracking_enabled: + feature_pos = self.feature_tracker.get_tracking_position(frame_number) + if feature_pos: + # Features are extracted from original frames, so coordinates are already correct + return feature_pos + + # Fall back to manual tracking points if not self.tracking_points: return None frames = sorted(self.tracking_points.keys()) @@ -1940,13 +2136,19 @@ class VideoEditor: motion_text = ( f" | Motion: {self.tracking_enabled}" if self.tracking_enabled else "" ) + feature_text = ( + f" | Features: {self.feature_tracker.tracking_enabled}" if self.feature_tracker.tracking_enabled else "" + ) + if self.feature_tracker.tracking_enabled and self.current_frame in self.feature_tracker.features: + feature_count = self.feature_tracker.get_feature_count(self.current_frame) + feature_text = f" | Features: {feature_count} pts" autorepeat_text = ( f" | Loop: ON" if self.looping_between_markers else "" ) if self.is_image_mode: - info_text = f"Image | Zoom: {self.zoom_factor:.1f}x{rotation_text}{brightness_text}{contrast_text}{motion_text}" + info_text = f"Image | Zoom: {self.zoom_factor:.1f}x{rotation_text}{brightness_text}{contrast_text}{motion_text}{feature_text}" else: - info_text = f"Frame: {self.current_frame}/{self.total_frames} | Speed: {self.playback_speed:.1f}x | Zoom: {self.zoom_factor:.1f}x{seek_multiplier_text}{rotation_text}{brightness_text}{contrast_text}{motion_text}{autorepeat_text} | {'Playing' if self.is_playing else 'Paused'}" + info_text = f"Frame: {self.current_frame}/{self.total_frames} | Speed: {self.playback_speed:.1f}x | Zoom: {self.zoom_factor:.1f}x{seek_multiplier_text}{rotation_text}{brightness_text}{contrast_text}{motion_text}{feature_text}{autorepeat_text} | {'Playing' if self.is_playing else 'Paused'}" cv2.putText( canvas, info_text, @@ -2039,6 +2241,16 @@ class VideoEditor: cv2.circle(canvas, (sx, sy), 6, (255, 0, 0), -1) cv2.circle(canvas, (sx, sy), 6, (255, 255, 255), 1) + # Draw feature tracking points (green circles) + if (not self.is_image_mode and + self.feature_tracker.tracking_enabled and + self.current_frame in self.feature_tracker.features): + feature_positions = self.feature_tracker.features[self.current_frame]['positions'] + for (fx, fy) in feature_positions: + sx, sy = self._map_rotated_to_screen(fx, fy) + cv2.circle(canvas, (sx, sy), 4, (0, 255, 0), -1) # Green circles for features + cv2.circle(canvas, (sx, sy), 4, (255, 255, 255), 1) + # Draw previous and next tracking points with motion path visualization if not self.is_image_mode and self.tracking_points: prev_result = self._get_previous_tracking_point() @@ -3273,6 +3485,47 @@ class VideoEditor: self.tracking_points = {} self.show_feedback_message("Tracking points cleared") self.save_state() + elif key == ord("f"): + # Toggle feature tracking on/off + self.feature_tracker.tracking_enabled = not self.feature_tracker.tracking_enabled + self.show_feedback_message(f"Feature tracking {'ON' if self.feature_tracker.tracking_enabled else 'OFF'}") + self.save_state() + elif key == ord("F"): + # Extract features from current frame + if not self.is_image_mode and self.current_display_frame is not None: + # Extract features from the original frame (before transformations) + # This ensures features are in the original coordinate system + success = self.feature_tracker.extract_features(self.current_display_frame, self.current_frame) + if success: + count = self.feature_tracker.get_feature_count(self.current_frame) + self.show_feedback_message(f"Extracted {count} features from frame {self.current_frame}") + else: + self.show_feedback_message("Failed to extract features") + self.save_state() + else: + self.show_feedback_message("No frame data available") + elif key == ord("g"): + # Toggle auto tracking + self.feature_tracker.auto_tracking = not self.feature_tracker.auto_tracking + self.show_feedback_message(f"Auto tracking {'ON' if self.feature_tracker.auto_tracking else 'OFF'}") + self.save_state() + elif key == ord("G"): + # Clear all feature tracking data + self.feature_tracker.clear_features() + self.show_feedback_message("Feature tracking data cleared") + self.save_state() + elif key == ord("h"): + # Switch detector type (SIFT -> SURF -> ORB -> SIFT) + current_type = self.feature_tracker.detector_type + if current_type == 'SIFT': + new_type = 'SURF' + elif current_type == 'SURF': + new_type = 'ORB' + else: + new_type = 'SIFT' + self.feature_tracker.set_detector_type(new_type) + self.show_feedback_message(f"Detector switched to {new_type}") + self.save_state() elif key == ord("t"): # Marker looping only for videos if not self.is_image_mode: diff --git a/uv.lock b/uv.lock index 1eb131b..1d34510 100644 --- a/uv.lock +++ b/uv.lock @@ -15,12 +15,14 @@ source = { virtual = "croppa" } dependencies = [ { name = "numpy" }, { name = "opencv-python" }, + { name = "pillow" }, ] [package.metadata] requires-dist = [ { name = "numpy", specifier = ">=1.24.0" }, { name = "opencv-python", specifier = ">=4.8.0" }, + { name = "pillow", specifier = ">=10.0.0" }, ] [[package]] @@ -85,6 +87,61 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fa/80/eb88edc2e2b11cd2dd2e56f1c80b5784d11d6e6b7f04a1145df64df40065/opencv_python-4.12.0.88-cp37-abi3-win_amd64.whl", hash = "sha256:d98edb20aa932fd8ebd276a72627dad9dc097695b3d435a4257557bbb49a79d2", size = 39000307, upload-time = "2025-07-07T09:14:16.641Z" }, ] +[[package]] +name = "pillow" +version = "11.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/0d/d0d6dea55cd152ce3d6767bb38a8fc10e33796ba4ba210cbab9354b6d238/pillow-11.3.0.tar.gz", hash = "sha256:3828ee7586cd0b2091b6209e5ad53e20d0649bbe87164a459d0676e035e8f523", size = 47113069, upload-time = "2025-07-01T09:16:30.666Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/93/0952f2ed8db3a5a4c7a11f91965d6184ebc8cd7cbb7941a260d5f018cd2d/pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:1c627742b539bba4309df89171356fcb3cc5a9178355b2727d1b74a6cf155fbd", size = 2128328, upload-time = "2025-07-01T09:14:35.276Z" }, + { url = "https://files.pythonhosted.org/packages/4b/e8/100c3d114b1a0bf4042f27e0f87d2f25e857e838034e98ca98fe7b8c0a9c/pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:30b7c02f3899d10f13d7a48163c8969e4e653f8b43416d23d13d1bbfdc93b9f8", size = 2170652, upload-time = "2025-07-01T09:14:37.203Z" }, + { url = "https://files.pythonhosted.org/packages/aa/86/3f758a28a6e381758545f7cdb4942e1cb79abd271bea932998fc0db93cb6/pillow-11.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7859a4cc7c9295f5838015d8cc0a9c215b77e43d07a25e460f35cf516df8626f", size = 2227443, upload-time = "2025-07-01T09:14:39.344Z" }, + { url = "https://files.pythonhosted.org/packages/01/f4/91d5b3ffa718df2f53b0dc109877993e511f4fd055d7e9508682e8aba092/pillow-11.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ec1ee50470b0d050984394423d96325b744d55c701a439d2bd66089bff963d3c", size = 5278474, upload-time = "2025-07-01T09:14:41.843Z" }, + { url = "https://files.pythonhosted.org/packages/f9/0e/37d7d3eca6c879fbd9dba21268427dffda1ab00d4eb05b32923d4fbe3b12/pillow-11.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7db51d222548ccfd274e4572fdbf3e810a5e66b00608862f947b163e613b67dd", size = 4686038, upload-time = "2025-07-01T09:14:44.008Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b0/3426e5c7f6565e752d81221af9d3676fdbb4f352317ceafd42899aaf5d8a/pillow-11.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2d6fcc902a24ac74495df63faad1884282239265c6839a0a6416d33faedfae7e", size = 5864407, upload-time = "2025-07-03T13:10:15.628Z" }, + { url = "https://files.pythonhosted.org/packages/fc/c1/c6c423134229f2a221ee53f838d4be9d82bab86f7e2f8e75e47b6bf6cd77/pillow-11.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f0f5d8f4a08090c6d6d578351a2b91acf519a54986c055af27e7a93feae6d3f1", size = 7639094, upload-time = "2025-07-03T13:10:21.857Z" }, + { url = "https://files.pythonhosted.org/packages/ba/c9/09e6746630fe6372c67c648ff9deae52a2bc20897d51fa293571977ceb5d/pillow-11.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c37d8ba9411d6003bba9e518db0db0c58a680ab9fe5179f040b0463644bc9805", size = 5973503, upload-time = "2025-07-01T09:14:45.698Z" }, + { url = "https://files.pythonhosted.org/packages/d5/1c/a2a29649c0b1983d3ef57ee87a66487fdeb45132df66ab30dd37f7dbe162/pillow-11.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:13f87d581e71d9189ab21fe0efb5a23e9f28552d5be6979e84001d3b8505abe8", size = 6642574, upload-time = "2025-07-01T09:14:47.415Z" }, + { url = "https://files.pythonhosted.org/packages/36/de/d5cc31cc4b055b6c6fd990e3e7f0f8aaf36229a2698501bcb0cdf67c7146/pillow-11.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:023f6d2d11784a465f09fd09a34b150ea4672e85fb3d05931d89f373ab14abb2", size = 6084060, upload-time = "2025-07-01T09:14:49.636Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ea/502d938cbaeec836ac28a9b730193716f0114c41325db428e6b280513f09/pillow-11.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:45dfc51ac5975b938e9809451c51734124e73b04d0f0ac621649821a63852e7b", size = 6721407, upload-time = "2025-07-01T09:14:51.962Z" }, + { url = "https://files.pythonhosted.org/packages/45/9c/9c5e2a73f125f6cbc59cc7087c8f2d649a7ae453f83bd0362ff7c9e2aee2/pillow-11.3.0-cp313-cp313-win32.whl", hash = "sha256:a4d336baed65d50d37b88ca5b60c0fa9d81e3a87d4a7930d3880d1624d5b31f3", size = 6273841, upload-time = "2025-07-01T09:14:54.142Z" }, + { url = "https://files.pythonhosted.org/packages/23/85/397c73524e0cd212067e0c969aa245b01d50183439550d24d9f55781b776/pillow-11.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0bce5c4fd0921f99d2e858dc4d4d64193407e1b99478bc5cacecba2311abde51", size = 6978450, upload-time = "2025-07-01T09:14:56.436Z" }, + { url = "https://files.pythonhosted.org/packages/17/d2/622f4547f69cd173955194b78e4d19ca4935a1b0f03a302d655c9f6aae65/pillow-11.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:1904e1264881f682f02b7f8167935cce37bc97db457f8e7849dc3a6a52b99580", size = 2423055, upload-time = "2025-07-01T09:14:58.072Z" }, + { url = "https://files.pythonhosted.org/packages/dd/80/a8a2ac21dda2e82480852978416cfacd439a4b490a501a288ecf4fe2532d/pillow-11.3.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:4c834a3921375c48ee6b9624061076bc0a32a60b5532b322cc0ea64e639dd50e", size = 5281110, upload-time = "2025-07-01T09:14:59.79Z" }, + { url = "https://files.pythonhosted.org/packages/44/d6/b79754ca790f315918732e18f82a8146d33bcd7f4494380457ea89eb883d/pillow-11.3.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5e05688ccef30ea69b9317a9ead994b93975104a677a36a8ed8106be9260aa6d", size = 4689547, upload-time = "2025-07-01T09:15:01.648Z" }, + { url = "https://files.pythonhosted.org/packages/49/20/716b8717d331150cb00f7fdd78169c01e8e0c219732a78b0e59b6bdb2fd6/pillow-11.3.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1019b04af07fc0163e2810167918cb5add8d74674b6267616021ab558dc98ced", size = 5901554, upload-time = "2025-07-03T13:10:27.018Z" }, + { url = "https://files.pythonhosted.org/packages/74/cf/a9f3a2514a65bb071075063a96f0a5cf949c2f2fce683c15ccc83b1c1cab/pillow-11.3.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f944255db153ebb2b19c51fe85dd99ef0ce494123f21b9db4877ffdfc5590c7c", size = 7669132, upload-time = "2025-07-03T13:10:33.01Z" }, + { url = "https://files.pythonhosted.org/packages/98/3c/da78805cbdbee9cb43efe8261dd7cc0b4b93f2ac79b676c03159e9db2187/pillow-11.3.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f85acb69adf2aaee8b7da124efebbdb959a104db34d3a2cb0f3793dbae422a8", size = 6005001, upload-time = "2025-07-01T09:15:03.365Z" }, + { url = "https://files.pythonhosted.org/packages/6c/fa/ce044b91faecf30e635321351bba32bab5a7e034c60187fe9698191aef4f/pillow-11.3.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05f6ecbeff5005399bb48d198f098a9b4b6bdf27b8487c7f38ca16eeb070cd59", size = 6668814, upload-time = "2025-07-01T09:15:05.655Z" }, + { url = "https://files.pythonhosted.org/packages/7b/51/90f9291406d09bf93686434f9183aba27b831c10c87746ff49f127ee80cb/pillow-11.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a7bc6e6fd0395bc052f16b1a8670859964dbd7003bd0af2ff08342eb6e442cfe", size = 6113124, upload-time = "2025-07-01T09:15:07.358Z" }, + { url = "https://files.pythonhosted.org/packages/cd/5a/6fec59b1dfb619234f7636d4157d11fb4e196caeee220232a8d2ec48488d/pillow-11.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:83e1b0161c9d148125083a35c1c5a89db5b7054834fd4387499e06552035236c", size = 6747186, upload-time = "2025-07-01T09:15:09.317Z" }, + { url = "https://files.pythonhosted.org/packages/49/6b/00187a044f98255225f172de653941e61da37104a9ea60e4f6887717e2b5/pillow-11.3.0-cp313-cp313t-win32.whl", hash = "sha256:2a3117c06b8fb646639dce83694f2f9eac405472713fcb1ae887469c0d4f6788", size = 6277546, upload-time = "2025-07-01T09:15:11.311Z" }, + { url = "https://files.pythonhosted.org/packages/e8/5c/6caaba7e261c0d75bab23be79f1d06b5ad2a2ae49f028ccec801b0e853d6/pillow-11.3.0-cp313-cp313t-win_amd64.whl", hash = "sha256:857844335c95bea93fb39e0fa2726b4d9d758850b34075a7e3ff4f4fa3aa3b31", size = 6985102, upload-time = "2025-07-01T09:15:13.164Z" }, + { url = "https://files.pythonhosted.org/packages/f3/7e/b623008460c09a0cb38263c93b828c666493caee2eb34ff67f778b87e58c/pillow-11.3.0-cp313-cp313t-win_arm64.whl", hash = "sha256:8797edc41f3e8536ae4b10897ee2f637235c94f27404cac7297f7b607dd0716e", size = 2424803, upload-time = "2025-07-01T09:15:15.695Z" }, + { url = "https://files.pythonhosted.org/packages/73/f4/04905af42837292ed86cb1b1dabe03dce1edc008ef14c473c5c7e1443c5d/pillow-11.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:d9da3df5f9ea2a89b81bb6087177fb1f4d1c7146d583a3fe5c672c0d94e55e12", size = 5278520, upload-time = "2025-07-01T09:15:17.429Z" }, + { url = "https://files.pythonhosted.org/packages/41/b0/33d79e377a336247df6348a54e6d2a2b85d644ca202555e3faa0cf811ecc/pillow-11.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0b275ff9b04df7b640c59ec5a3cb113eefd3795a8df80bac69646ef699c6981a", size = 4686116, upload-time = "2025-07-01T09:15:19.423Z" }, + { url = "https://files.pythonhosted.org/packages/49/2d/ed8bc0ab219ae8768f529597d9509d184fe8a6c4741a6864fea334d25f3f/pillow-11.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0743841cabd3dba6a83f38a92672cccbd69af56e3e91777b0ee7f4dba4385632", size = 5864597, upload-time = "2025-07-03T13:10:38.404Z" }, + { url = "https://files.pythonhosted.org/packages/b5/3d/b932bb4225c80b58dfadaca9d42d08d0b7064d2d1791b6a237f87f661834/pillow-11.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2465a69cf967b8b49ee1b96d76718cd98c4e925414ead59fdf75cf0fd07df673", size = 7638246, upload-time = "2025-07-03T13:10:44.987Z" }, + { url = "https://files.pythonhosted.org/packages/09/b5/0487044b7c096f1b48f0d7ad416472c02e0e4bf6919541b111efd3cae690/pillow-11.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41742638139424703b4d01665b807c6468e23e699e8e90cffefe291c5832b027", size = 5973336, upload-time = "2025-07-01T09:15:21.237Z" }, + { url = "https://files.pythonhosted.org/packages/a8/2d/524f9318f6cbfcc79fbc004801ea6b607ec3f843977652fdee4857a7568b/pillow-11.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:93efb0b4de7e340d99057415c749175e24c8864302369e05914682ba642e5d77", size = 6642699, upload-time = "2025-07-01T09:15:23.186Z" }, + { url = "https://files.pythonhosted.org/packages/6f/d2/a9a4f280c6aefedce1e8f615baaa5474e0701d86dd6f1dede66726462bbd/pillow-11.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7966e38dcd0fa11ca390aed7c6f20454443581d758242023cf36fcb319b1a874", size = 6083789, upload-time = "2025-07-01T09:15:25.1Z" }, + { url = "https://files.pythonhosted.org/packages/fe/54/86b0cd9dbb683a9d5e960b66c7379e821a19be4ac5810e2e5a715c09a0c0/pillow-11.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:98a9afa7b9007c67ed84c57c9e0ad86a6000da96eaa638e4f8abe5b65ff83f0a", size = 6720386, upload-time = "2025-07-01T09:15:27.378Z" }, + { url = "https://files.pythonhosted.org/packages/e7/95/88efcaf384c3588e24259c4203b909cbe3e3c2d887af9e938c2022c9dd48/pillow-11.3.0-cp314-cp314-win32.whl", hash = "sha256:02a723e6bf909e7cea0dac1b0e0310be9d7650cd66222a5f1c571455c0a45214", size = 6370911, upload-time = "2025-07-01T09:15:29.294Z" }, + { url = "https://files.pythonhosted.org/packages/2e/cc/934e5820850ec5eb107e7b1a72dd278140731c669f396110ebc326f2a503/pillow-11.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:a418486160228f64dd9e9efcd132679b7a02a5f22c982c78b6fc7dab3fefb635", size = 7117383, upload-time = "2025-07-01T09:15:31.128Z" }, + { url = "https://files.pythonhosted.org/packages/d6/e9/9c0a616a71da2a5d163aa37405e8aced9a906d574b4a214bede134e731bc/pillow-11.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:155658efb5e044669c08896c0c44231c5e9abcaadbc5cd3648df2f7c0b96b9a6", size = 2511385, upload-time = "2025-07-01T09:15:33.328Z" }, + { url = "https://files.pythonhosted.org/packages/1a/33/c88376898aff369658b225262cd4f2659b13e8178e7534df9e6e1fa289f6/pillow-11.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:59a03cdf019efbfeeed910bf79c7c93255c3d54bc45898ac2a4140071b02b4ae", size = 5281129, upload-time = "2025-07-01T09:15:35.194Z" }, + { url = "https://files.pythonhosted.org/packages/1f/70/d376247fb36f1844b42910911c83a02d5544ebd2a8bad9efcc0f707ea774/pillow-11.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f8a5827f84d973d8636e9dc5764af4f0cf2318d26744b3d902931701b0d46653", size = 4689580, upload-time = "2025-07-01T09:15:37.114Z" }, + { url = "https://files.pythonhosted.org/packages/eb/1c/537e930496149fbac69efd2fc4329035bbe2e5475b4165439e3be9cb183b/pillow-11.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ee92f2fd10f4adc4b43d07ec5e779932b4eb3dbfbc34790ada5a6669bc095aa6", size = 5902860, upload-time = "2025-07-03T13:10:50.248Z" }, + { url = "https://files.pythonhosted.org/packages/bd/57/80f53264954dcefeebcf9dae6e3eb1daea1b488f0be8b8fef12f79a3eb10/pillow-11.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c96d333dcf42d01f47b37e0979b6bd73ec91eae18614864622d9b87bbd5bbf36", size = 7670694, upload-time = "2025-07-03T13:10:56.432Z" }, + { url = "https://files.pythonhosted.org/packages/70/ff/4727d3b71a8578b4587d9c276e90efad2d6fe0335fd76742a6da08132e8c/pillow-11.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4c96f993ab8c98460cd0c001447bff6194403e8b1d7e149ade5f00594918128b", size = 6005888, upload-time = "2025-07-01T09:15:39.436Z" }, + { url = "https://files.pythonhosted.org/packages/05/ae/716592277934f85d3be51d7256f3636672d7b1abfafdc42cf3f8cbd4b4c8/pillow-11.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:41342b64afeba938edb034d122b2dda5db2139b9a4af999729ba8818e0056477", size = 6670330, upload-time = "2025-07-01T09:15:41.269Z" }, + { url = "https://files.pythonhosted.org/packages/e7/bb/7fe6cddcc8827b01b1a9766f5fdeb7418680744f9082035bdbabecf1d57f/pillow-11.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:068d9c39a2d1b358eb9f245ce7ab1b5c3246c7c8c7d9ba58cfa5b43146c06e50", size = 6114089, upload-time = "2025-07-01T09:15:43.13Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f5/06bfaa444c8e80f1a8e4bff98da9c83b37b5be3b1deaa43d27a0db37ef84/pillow-11.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a1bc6ba083b145187f648b667e05a2534ecc4b9f2784c2cbe3089e44868f2b9b", size = 6748206, upload-time = "2025-07-01T09:15:44.937Z" }, + { url = "https://files.pythonhosted.org/packages/f0/77/bc6f92a3e8e6e46c0ca78abfffec0037845800ea38c73483760362804c41/pillow-11.3.0-cp314-cp314t-win32.whl", hash = "sha256:118ca10c0d60b06d006be10a501fd6bbdfef559251ed31b794668ed569c87e12", size = 6377370, upload-time = "2025-07-01T09:15:46.673Z" }, + { url = "https://files.pythonhosted.org/packages/4a/82/3a721f7d69dca802befb8af08b7c79ebcab461007ce1c18bd91a5d5896f9/pillow-11.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8924748b688aa210d79883357d102cd64690e56b923a186f35a82cbc10f997db", size = 7121500, upload-time = "2025-07-01T09:15:48.512Z" }, + { url = "https://files.pythonhosted.org/packages/89/c7/5572fa4a3f45740eaab6ae86fcdf7195b55beac1371ac8c619d880cfe948/pillow-11.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:79ea0d14d3ebad43ec77ad5272e6ff9bba5b679ef73375ea760261207fa8e0aa", size = 2512835, upload-time = "2025-07-01T09:15:50.399Z" }, +] + [[package]] name = "ruff" version = "0.12.12"