From 080f277b84be7a725a2834841011c3e459c3ba7a Mon Sep 17 00:00:00 2001 From: Tyler Romero Date: Sat, 7 Dec 2024 11:52:13 -0800 Subject: [PATCH 1/6] Refactor to use framegrab --- Makefile | 23 ++++++- pyproject.toml | 2 +- src/stream/grabber.py | 2 +- src/stream/grabber2.py | 113 ++++++++++++++++++++++++++++++++++ src/stream/main.py | 38 ++++++++---- test/test_grabber_creation.py | 107 ++++++++++++++++++++++++++++++++ 6 files changed, 269 insertions(+), 16 deletions(-) create mode 100644 src/stream/grabber2.py create mode 100644 test/test_grabber_creation.py diff --git a/Makefile b/Makefile index 864e3a8..3dd3841 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,16 @@ -.PHONY: build install install-dev test +.PHONY: help build install install-dev test + +help: + @echo "Available commands:" + @echo " help - Show this help message" + @echo " build - Build Docker image" + @echo " install - Install package" + @echo " install-dev - Install package with dev dependencies" + @echo " install-uv - Install package using uv" + @echo " install-dev-uv - Install package with dev dependencies using uv" + @echo " test - Run tests" + @echo " test-uv - Run tests using uv" + @echo " relock - Update lockfile using uv" build: docker build -t stream:local . @@ -9,8 +21,17 @@ install: install-dev: pip install -e .[dev] +install-uv: + uv pip install -e . + +install-dev-uv: + uv pip install -e .[dev] + test: pytest +test-uv: + uv run pytest + relock: uv lock \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index d9e12e0..a101de3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ name = "stream" version = "0.5.2" description = "Groundlight Stream Processor - Container for analyzing video using RTSP etc" readme = "README.md" -requires-python = ">=3.10" +requires-python = ">=3.11" dependencies = [ "framegrab>=0.7.0", "groundlight>=0.18.4", diff --git a/src/stream/grabber.py b/src/stream/grabber.py index 2d8ef50..df7ece6 100644 --- a/src/stream/grabber.py +++ b/src/stream/grabber.py @@ -19,7 +19,7 @@ class FrameGrabber(metaclass=ABCMeta): @staticmethod def create_grabber(stream=None, stream_type=None, **kwargs): logger.debug(f"Input {stream=} (type {type(stream)}") - if (type(stream) == int and not streamtype) or stream_type == "device": + if (type(stream) == int and not stream_type) or stream_type == "device": logger.debug("Looking for camera {stream=}") return DeviceFrameGrabber(stream=stream) elif ((type(stream) == str) and (stream.find("*") != -1) and not stream_type) or stream_type == "directory": diff --git a/src/stream/grabber2.py b/src/stream/grabber2.py new file mode 100644 index 0000000..432973f --- /dev/null +++ b/src/stream/grabber2.py @@ -0,0 +1,113 @@ +import logging +from enum import StrEnum +from pathlib import Path +from typing import Any + +from framegrab import FrameGrabber + +logger = logging.getLogger("groundlight.stream") + + +class StreamType(StrEnum): + GENERIC_USB = "generic_usb" + RTSP = "rtsp" + HLS = "hls" + YOUTUBE_LIVE = "youtube_live" + DIRECTORY = "directory" # Not implemented yet + FILE = "file" # Not implemented yet + IMAGE_URL = "image_url" # Not implemented yet + + +def _infer_stream_type(stream: str | int) -> StreamType: + """Infer the stream type from the stream source.""" + if isinstance(stream, int): + return StreamType.GENERIC_USB + + if not isinstance(stream, str): + raise TypeError(f"Stream must be a string or int, got {type(stream)}") + + logger.debug(f"Stream {stream} is not an int, inferring type from string") + + # Check URL patterns + if stream.startswith("rtsp://"): + return StreamType.RTSP + if stream.startswith("http"): + if stream.endswith(".m3u8"): + return StreamType.HLS + if stream.startswith("https://www.youtube.com"): + return StreamType.YOUTUBE_LIVE + raise NotImplementedError("Image URL stream type is not supported yet") + + # Check file patterns + if "*" in stream: + raise NotImplementedError("Directory stream type is not supported yet") + if Path(stream).is_file(): + raise NotImplementedError("File stream type is not supported yet") + + raise ValueError(f"Could not infer stream type from: {stream}") + + +def _stream_to_id(stream: str | int, stream_type: StreamType) -> dict[str, str | int] | None: + if stream_type == StreamType.YOUTUBE_LIVE: + return {"youtube_url": stream} + elif stream_type == StreamType.RTSP: + return {"rtsp_url": stream} + elif stream_type == StreamType.HLS: + return {"hls_url": stream} + elif stream_type == StreamType.GENERIC_USB: + return {"serial_number": stream} + return None + + +def _configure_options( + stream_type: StreamType, + height: int | None = None, + width: int | None = None, + max_fps: int | None = None, + keep_connection_open: bool | None = None, +) -> dict: + options = {} + + if height is not None: + options["resolution.height"] = height + if width is not None: + options["resolution.width"] = width + + if max_fps is not None: + if stream_type == StreamType.RTSP: + options["max_fps"] = max_fps + else: + logger.warning(f"max_fps is not supported for stream type {stream_type}") + + if keep_connection_open is not None: + if stream_type in [StreamType.RTSP, StreamType.YOUTUBE_LIVE, StreamType.HLS]: + options["keep_connection_open"] = keep_connection_open + logger.info(f"keep_connection_open set to {keep_connection_open}") + else: + logger.debug(f"keep_connection_open is not supported for stream type {stream_type}") + + return options + + +def framegrabber_factory( # noqa: PLR0913 + stream: str | int, + stream_type: StreamType | None = None, + height: int | None = None, + width: int | None = None, + max_fps: int | None = None, + keep_connection_open: bool | None = None, +) -> FrameGrabber: + if stream_type is None: + stream_type = _infer_stream_type(stream) + + grabber_config: dict[str, Any] = {"input_type": stream_type} + stream_id = _stream_to_id(stream, stream_type) + if stream_id is not None: + grabber_config["id"] = stream_id + + grabber_options = _configure_options(stream_type, height, width, max_fps, keep_connection_open) + if len(grabber_options) > 0: + grabber_config["options"] = grabber_options + + grabber = FrameGrabber.create_grabber(config=grabber_config) + return grabber diff --git a/src/stream/main.py b/src/stream/main.py index ab8eccb..91c590a 100644 --- a/src/stream/main.py +++ b/src/stream/main.py @@ -11,11 +11,11 @@ import cv2 import yaml -from framegrab import MotionDetector +from framegrab import FrameGrabber, MotionDetector from groundlight import Groundlight -from stream.grabber import FrameGrabber -from stream.image_processing import crop_frame, parse_crop_string, resize_if_needed +from stream.grabber2 import StreamType, framegrabber_factory +from stream.image_processing import crop_frame, parse_crop_string from stream.threads import setup_workers fname = os.path.join(os.path.dirname(__file__), "logging.yaml") @@ -68,7 +68,7 @@ def process_single_frame(frame: cv2.Mat, gl: Groundlight, detector: str) -> None logger.error(f"Exception while processing frame : {e}", exc_info=True) -def validate_stream_args(args: argparse.Namespace) -> tuple[str | int, str | None]: +def validate_stream_args(args: argparse.Namespace) -> tuple[str | int, StreamType | None]: """Parse and validate stream source arguments""" stream = args.stream stream_type = args.streamtype.lower() @@ -79,6 +79,8 @@ def validate_stream_args(args: argparse.Namespace) -> tuple[str | int, str | Non except ValueError: logger.debug(f"{stream=} is not an int. Treating as a filename or url.") stream_type = None + else: + stream_type = StreamType(stream_type) return stream, stream_type @@ -103,8 +105,6 @@ def run_capture_loop( # noqa: PLR0912 PLR0913 motion_detector: MotionDetector | None, post_motion_time: float, max_frame_interval: float, - resize_width: int, - resize_height: int, crop_region: tuple[float, float, float, float] | None, ) -> None: """Main capture loop implementation""" @@ -128,10 +128,10 @@ def run_capture_loop( # noqa: PLR0912 PLR0913 now = time.time() logger.debug(f"Captured frame in {now-start:.3f}s, size {frame.shape}") - # Apply cropping if configured + # Apply cropping if configured, needs to happen before motion detection if crop_region: original_shape = frame.shape - frame = crop_frame(frame, crop_region) + frame = crop_frame(frame, crop_region) # type: ignore logger.debug(f"Cropped frame from {original_shape} to {frame.shape}") # Determine if we should process this frame @@ -153,7 +153,6 @@ def run_capture_loop( # noqa: PLR0912 PLR0913 # Add frame to work queue if add_frame_to_queue: - frame = resize_if_needed(frame, resize_width, resize_height) queue.put(frame) last_frame_time = time.time() @@ -243,6 +242,15 @@ def main(): help="Max seconds between frames even without motion. Defaults to 1000 seconds.", ) + # Stream options + parser.add_argument( + "-k", + "--keep-connection-open", + action="store_true", + default=False, + help="Keep connection open for low-latency frame grabbing (uses more CPU). Defaults to false.", + ) + # Image pre-processing parser.add_argument("-w", "--width", dest="resize_width", type=int, default=0, help="Resize width in pixels.") parser.add_argument("-y", "--height", dest="resize_height", type=int, default=0, help="Resize height in pixels.") @@ -269,8 +277,14 @@ def main(): logger.debug(f"Groundlight client created, whoami={gl.whoami()}") # Setup frame grabber - grabber_config = dict(stream=stream, stream_type=stream_type, fps_target=args.fps) - grabber = FrameGrabber.create_grabber(**grabber_config) + grabber = framegrabber_factory( + stream=stream, + stream_type=stream_type, + height=args.resize_height, + width=args.resize_width, + max_fps=args.fps, + keep_connection_open=args.keep_connection_open, + ) # Setup workers worker_count = 10 if args.fps == 0 else math.ceil(args.fps) @@ -291,8 +305,6 @@ def main(): motion_detector=motion_detector, post_motion_time=post_motion_time, max_frame_interval=max_frame_interval, - resize_width=args.resize_width, - resize_height=args.resize_height, crop_region=crop_region, ) except KeyboardInterrupt: diff --git a/test/test_grabber_creation.py b/test/test_grabber_creation.py new file mode 100644 index 0000000..2e25e18 --- /dev/null +++ b/test/test_grabber_creation.py @@ -0,0 +1,107 @@ +import pytest +from framegrab import FrameGrabber + +from stream.grabber2 import StreamType, _configure_options, _infer_stream_type, _stream_to_id, framegrabber_factory + + +def test_infer_stream_type(): + # Test integer input + assert _infer_stream_type(0) == StreamType.GENERIC_USB + assert _infer_stream_type(1) == StreamType.GENERIC_USB + assert _infer_stream_type(2) == StreamType.GENERIC_USB + + # Test RTSP URLs + assert _infer_stream_type("rtsp://example.com/stream") == StreamType.RTSP + + # Test HLS URLs + assert _infer_stream_type("http://example.com/stream.m3u8") == StreamType.HLS + assert _infer_stream_type("https://example.com/stream.m3u8") == StreamType.HLS + + # Test YouTube URLs + assert _infer_stream_type("https://www.youtube.com/watch?v=123") == StreamType.YOUTUBE_LIVE + + # Test invalid inputs + with pytest.raises(TypeError): + _infer_stream_type(None) + with pytest.raises(TypeError): + _infer_stream_type(1.0) + with pytest.raises(ValueError): + _infer_stream_type("invalid://stream") + + # Test unimplemented types + with pytest.raises(NotImplementedError): + _infer_stream_type("http://example.com/image.jpg") + with pytest.raises(NotImplementedError): + _infer_stream_type("*.jpg") + + +def test_stream_to_id(): + # Test YouTube stream + assert _stream_to_id("https://youtube.com/123", StreamType.YOUTUBE_LIVE) == { + "youtube_url": "https://youtube.com/123" + } + + # Test RTSP stream + assert _stream_to_id("rtsp://example.com", StreamType.RTSP) == {"rtsp_url": "rtsp://example.com"} + + # Test HLS stream + assert _stream_to_id("http://example.com/stream.m3u8", StreamType.HLS) == { + "hls_url": "http://example.com/stream.m3u8" + } + + # Test USB device + assert _stream_to_id(0, StreamType.GENERIC_USB) == {"serial_number": 0} + + # Test unimplemented types + assert _stream_to_id("test.jpg", StreamType.FILE) is None + assert _stream_to_id("*.jpg", StreamType.DIRECTORY) is None + assert _stream_to_id("http://example.com/img.jpg", StreamType.IMAGE_URL) is None + + +def test_configure_options(): + # Test resolution options + opts = _configure_options(StreamType.RTSP, height=480, width=640) + assert opts["resolution.height"] == 480 + assert opts["resolution.width"] == 640 + + # Test max_fps option + opts = _configure_options(StreamType.RTSP, max_fps=30) + assert opts["max_fps"] == 30 + + # Test max_fps warning for unsupported types + opts = _configure_options(StreamType.GENERIC_USB, max_fps=30) + assert "max_fps" not in opts + + # Test keep_connection_open for supported types + opts = _configure_options(StreamType.RTSP, keep_connection_open=True) + assert opts["keep_connection_open"] is True + + opts = _configure_options(StreamType.YOUTUBE_LIVE, keep_connection_open=True) + assert opts["keep_connection_open"] is True + + opts = _configure_options(StreamType.HLS, keep_connection_open=True) + assert opts["keep_connection_open"] is True + + # Test keep_connection_open for unsupported types + opts = _configure_options(StreamType.GENERIC_USB, keep_connection_open=True) + assert "keep_connection_open" not in opts + + +def test_framegrabber_factory(): + # Test USB camera creation + grabber = framegrabber_factory(0) + assert isinstance(grabber, FrameGrabber) + + # Test RTSP stream creation with options + grabber = framegrabber_factory( + "rtsp://example.com", stream_type=StreamType.RTSP, height=480, width=640, max_fps=30, keep_connection_open=True + ) + assert isinstance(grabber, FrameGrabber) + + # Test stream type inference + grabber = framegrabber_factory("rtsp://example.com") + assert isinstance(grabber, FrameGrabber) + + # Test invalid stream + with pytest.raises(ValueError): + framegrabber_factory("invalid://stream") From 630add9592387e2ce6ad8e7583e1b891a939e888 Mon Sep 17 00:00:00 2001 From: Tyler Romero Date: Sat, 7 Dec 2024 12:44:42 -0800 Subject: [PATCH 2/6] Lots more polish --- .gitignore | 3 + Makefile | 4 +- README.md | 40 ++-- pyproject.toml | 8 +- src/stream/grabber.py | 390 ++++++++------------------------- src/stream/grabber2.py | 113 ---------- src/stream/image_processing.py | 29 --- src/stream/logging.yaml | 9 +- src/stream/main.py | 42 ++-- test/test_arg_parsing.py | 8 +- test/test_grabber_creation.py | 22 +- test/test_image_processing.py | 27 +-- test/test_image_submission.py | 8 - uv.lock | 247 +++++---------------- 14 files changed, 216 insertions(+), 734 deletions(-) delete mode 100644 src/stream/grabber2.py diff --git a/.gitignore b/.gitignore index b6e4761..1d8aa3d 100644 --- a/.gitignore +++ b/.gitignore @@ -117,6 +117,9 @@ venv.bak/ # Rope project settings .ropeproject +# VSCode project settings +.vscode + # mkdocs documentation /site diff --git a/Makefile b/Makefile index 3dd3841..0050e1d 100644 --- a/Makefile +++ b/Makefile @@ -22,10 +22,10 @@ install-dev: pip install -e .[dev] install-uv: - uv pip install -e . + uv sync install-dev-uv: - uv pip install -e .[dev] + uv sync --all-extras test: pytest diff --git a/README.md b/README.md index da1eb04..4c109f4 100644 --- a/README.md +++ b/README.md @@ -35,39 +35,40 @@ Groundlight Stream Processor A command-line tool that captures frames from a video source and sends them to a Groundlight detector for analysis. Supports a variety of input sources including: -- Video devices (webcams) -- Video files (mp4, etc) +- Video devices (usb cameras, webcams, etc) - RTSP streams -- YouTube videos +- YouTube Live streams +- HLS streams - Image directories +- Video files (mp4, etc) - Image URLs options: -h, --help show this help message and exit - -t TOKEN, --token TOKEN - Groundlight API token for authentication. - -d DETECTOR, --detector DETECTOR + -t, --token TOKEN Groundlight API token for authentication. + -d, --detector DETECTOR Detector ID to send ImageQueries to. - -e ENDPOINT, --endpoint ENDPOINT + -e, --endpoint ENDPOINT API endpoint to target. For example, could be pointed at an edge-endpoint proxy server (https://github.com/groundlight/edge-endpoint). - -s STREAM, --stream STREAM - Video source. A device ID, filename, or URL. Defaults to device ID '0'. - -x {infer,device,directory,rtsp,youtube,file,image_url}, --streamtype {infer,device,directory,rtsp,youtube,file,image_url} + -s, --stream STREAM Video source. A device ID, filename, or URL. Defaults to device ID '0'. + -x, --streamtype {infer,device,directory,rtsp,youtube,file,image_url} Source type. Defaults to 'infer' which will attempt to set this value based on --stream. - -f FPS, --fps FPS Frames per second to capture (0 for max rate). Defaults to 1 FPS. + -f, --fps FPS Frames per second to capture (0 for max rate). Defaults to 1 FPS. -v, --verbose Enable debug logging. -m, --motion Enables motion detection, which is disabled by default. - -r THRESHOLD, --threshold THRESHOLD + -r, --threshold THRESHOLD Motion detection threshold (% pixels changed). Defaults to 1%. - -p POSTMOTION, --postmotion POSTMOTION + -p, --postmotion POSTMOTION Seconds to capture after motion detected. Defaults to 1 second. - -i MAXINTERVAL, --maxinterval MAXINTERVAL + -i, --maxinterval MAXINTERVAL Max seconds between frames even without motion. Defaults to 1000 seconds. - -w RESIZE_WIDTH, --width RESIZE_WIDTH + -k, --keep-connection-open + Keep connection open for low-latency frame grabbing (uses more CPU and network bandwidth). Defaults to false. + -w, --width RESIZE_WIDTH Resize width in pixels. - -y RESIZE_HEIGHT, --height RESIZE_HEIGHT + -y, --height RESIZE_HEIGHT Resize height in pixels. - -c CROP, --crop CROP Crop region, specified as fractions (0-1) of each dimension (e.g. '0.25,0.2,0.8,0.9'). + -c, --crop CROP Crop region, specified as fractions (0-1) of each dimension (e.g. '0.25,0.2,0.8,0.9'). ``` Start sending frames and getting predictions and labels using your own API token and detector ID: @@ -109,10 +110,11 @@ docker run groundlight/stream \ -t api_29imEXAMPLE \ -d det_2MiD5Elu8bza7sil9l7KPpr694a \ -s "${YOUTUBE_URL}" \ - -f 1 + -k \ + -f 5 ``` -Replace `YOUTUBE_URL` with the url of the YouTube video you are interested in. +Replace `YOUTUBE_URL` with the url of the YouTube video you are interested in. The `-k` parameter is used to keep the connection open for low-latency frame grabbing. This uses more CPU and network bandwidth but can provide faster frame rates. ### Connecting an RTSP Stream diff --git a/pyproject.toml b/pyproject.toml index a101de3..6f94cd7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,12 +1,12 @@ [project] name = "stream" -version = "0.5.2" +version = "0.6.0" description = "Groundlight Stream Processor - Container for analyzing video using RTSP etc" readme = "README.md" requires-python = ">=3.11" dependencies = [ - "framegrab>=0.7.0", - "groundlight>=0.18.4", + "framegrab>=0.8.0", + "groundlight>=0.20.0", "jsonformatter>=0.3.2", "numpy<2.0.0", "opencv-python-headless>=4.10.0.84", @@ -16,7 +16,7 @@ dependencies = [ [project.optional-dependencies] dev = [ - "pytest>=7.0", + "pytest>=8.0", "ruff==0.7.2", ] diff --git a/src/stream/grabber.py b/src/stream/grabber.py index df7ece6..432973f 100644 --- a/src/stream/grabber.py +++ b/src/stream/grabber.py @@ -1,309 +1,113 @@ -import fnmatch import logging -import os -import random -import time -import urllib -from abc import ABCMeta, abstractmethod +from enum import StrEnum from pathlib import Path -from threading import Lock, Thread +from typing import Any -import cv2 -import numpy as np -import streamlink +from framegrab import FrameGrabber logger = logging.getLogger("groundlight.stream") -class FrameGrabber(metaclass=ABCMeta): - @staticmethod - def create_grabber(stream=None, stream_type=None, **kwargs): - logger.debug(f"Input {stream=} (type {type(stream)}") - if (type(stream) == int and not stream_type) or stream_type == "device": - logger.debug("Looking for camera {stream=}") - return DeviceFrameGrabber(stream=stream) - elif ((type(stream) == str) and (stream.find("*") != -1) and not stream_type) or stream_type == "directory": - logger.debug(f"Found wildcard file {stream=}") - return DirectoryFrameGrabber(stream=stream) - elif ((type(stream) == str) and (stream[:4] == "rtsp") and not stream_type) or stream_type == "rtsp": - logger.debug(f"found rtsp stream {stream=}") - return RTSPFrameGrabber(stream=stream) - elif ( - (type(stream) == str) and (stream.find("youtube.com") > 0) and not stream_type - ) or stream_type == "youtube": - logger.debug(f"found youtube stream {stream=}") - return YouTubeFrameGrabber(stream=stream) - elif ((type(stream) == str) and Path(stream).is_file() and not stream_type) or stream_type == "file": - logger.debug(f"found filename stream {stream=}") - return FileStreamFrameGrabber(stream=stream, **kwargs) - elif ((type(stream) == str) and (stream[:4] == "http") and not stream_type) or stream_type == "image_url": - logger.debug(f"found image url {stream=}") - return ImageURLFrameGrabber(url=stream, **kwargs) +class StreamType(StrEnum): + GENERIC_USB = "generic_usb" + RTSP = "rtsp" + HLS = "hls" + YOUTUBE_LIVE = "youtube_live" + DIRECTORY = "directory" # Not implemented yet + FILE = "file" # Not implemented yet + IMAGE_URL = "image_url" # Not implemented yet + + +def _infer_stream_type(stream: str | int) -> StreamType: + """Infer the stream type from the stream source.""" + if isinstance(stream, int): + return StreamType.GENERIC_USB + + if not isinstance(stream, str): + raise TypeError(f"Stream must be a string or int, got {type(stream)}") + + logger.debug(f"Stream {stream} is not an int, inferring type from string") + + # Check URL patterns + if stream.startswith("rtsp://"): + return StreamType.RTSP + if stream.startswith("http"): + if stream.endswith(".m3u8"): + return StreamType.HLS + if stream.startswith("https://www.youtube.com"): + return StreamType.YOUTUBE_LIVE + raise NotImplementedError("Image URL stream type is not supported yet") + + # Check file patterns + if "*" in stream: + raise NotImplementedError("Directory stream type is not supported yet") + if Path(stream).is_file(): + raise NotImplementedError("File stream type is not supported yet") + + raise ValueError(f"Could not infer stream type from: {stream}") + + +def _stream_to_id(stream: str | int, stream_type: StreamType) -> dict[str, str | int] | None: + if stream_type == StreamType.YOUTUBE_LIVE: + return {"youtube_url": stream} + elif stream_type == StreamType.RTSP: + return {"rtsp_url": stream} + elif stream_type == StreamType.HLS: + return {"hls_url": stream} + elif stream_type == StreamType.GENERIC_USB: + return {"serial_number": stream} + return None + + +def _configure_options( + stream_type: StreamType, + height: int | None = None, + width: int | None = None, + max_fps: int | None = None, + keep_connection_open: bool | None = None, +) -> dict: + options = {} + + if height is not None: + options["resolution.height"] = height + if width is not None: + options["resolution.width"] = width + + if max_fps is not None: + if stream_type == StreamType.RTSP: + options["max_fps"] = max_fps else: - raise ValueError(f"cannot create a frame grabber from {stream=} {stream_type=}") + logger.warning(f"max_fps is not supported for stream type {stream_type}") - @abstractmethod - def grab(): - pass - - -class DirectoryFrameGrabber(FrameGrabber): - def __init__(self, stream=None): - """stream must be an file mask""" - try: - self.filename_list = [] - for filename in os.listdir(): - if fnmatch.fnmatch(filename, stream): - self.filename_list.append(filename) - logger.debug(f"found {len(self.filename_list)} files matching {stream=}") - random.shuffle(self.filename_list) - except Exception as e: - logger.error(f"could not initialize DirectoryFrameGrabber: {stream=} filename is invalid or read error") - raise e - if len(self.filename_list) == 0: - logger.warning(f"no files found matching {stream=}") - - def grab(self): - if len(self.filename_list) == 0: - raise RuntimeWarning("could not read frame from {self.capture=}. possible end of file.") - - start = time.time() - frame = cv2.imread(self.filename_list[0], cv2.IMREAD_GRAYSCALE) - self.filename_list.pop(0) - logger.debug(f"read the frame in {1000*(time.time()-start):.1f}ms") - - return frame - - -class FileStreamFrameGrabber(FrameGrabber): - def __init__(self, stream=None, fps_target=0): - """stream must be an filename""" - try: - self.capture = cv2.VideoCapture(stream) - logger.debug(f"initialized video capture with backend={self.capture.getBackendName()}") - ret, frame = self.capture.read() - self.fps_source = round(self.capture.get(cv2.CAP_PROP_FPS), 2) - self.fps_target = fps_target - logger.debug(f"source FPS : {self.fps_source=} / target FPS : {self.fps_target}") - self.remainder = 0.0 - except Exception as e: - logger.error(f"could not initialize DeviceFrameGrabber: {stream=} filename is invalid or read error") - raise e - - def grab(self): - """decimates stream to self.fps_target, 0 fps to use full original stream. - consistent with existing behavior based on VideoCapture.read() - which may return None when it cannot read a frame. - """ - start = time.time() - - if self.fps_target > 0 and self.fps_target < self.fps_source: - drop_frames = (self.fps_source / self.fps_target) - 1 + self.remainder - for i in range(round(drop_frames)): - ret, frame = self.capture.read() - self.remainder = round(drop_frames - round(drop_frames), 2) - logger.info( - f"dropped {round(drop_frames)} frames to meet {self.fps_target} FPS target from {self.fps_source} FPS source (off by {self.remainder} frames)" - ) + if keep_connection_open is not None: + if stream_type in [StreamType.RTSP, StreamType.YOUTUBE_LIVE, StreamType.HLS]: + options["keep_connection_open"] = keep_connection_open + logger.info(f"keep_connection_open set to {keep_connection_open}") else: - logger.debug(f"frame dropping disabled for {self.fps_target} FPS target from {self.fps_source} FPS source") - - ret, frame = self.capture.read() - if not ret: - raise RuntimeWarning("could not read frame from {self.capture=}. possible end of file.") - now = time.time() - logger.debug(f"read the frame in {1000*(now-start):.1f}ms") - return frame - - -class DeviceFrameGrabber(FrameGrabber): - """Grabs frames directly from a device via a VideoCapture object that - is kept open for the lifetime of this instance. - - importantly, this grabber does not buffer frames on behalf of the - caller, so each call to grab will directly read a frame from the - device - """ - - def __init__(self, stream=None): - """stream must be an int representing a device id""" - try: - self.capture = cv2.VideoCapture(int(stream)) - logger.debug(f"initialized video capture with backend={self.capture.getBackendName()}") - except Exception as e: - logger.error( - f"could not initialize DeviceFrameGrabber: {stream=} must be an int corresponding to a valid device id." - ) - raise e - - def grab(self): - """consistent with existing behavior based on VideoCapture.read() - which may return None when it cannot read a frame. - """ - start = time.time() - ret, frame = self.capture.read() - if not ret: - raise RuntimeWarning("could not read frame from {self.capture=}") - now = time.time() - logger.debug(f"read the frame in {1000*(now-start):.1f}ms") - return frame - - -class RTSPFrameGrabber(FrameGrabber): - """grabs the most recent frame from an rtsp stream. The RTSP capture - object has a non-configurable built-in buffer, so just calling - grab would return the oldest frame in the buffer rather than the - latest frame. This class uses a thread to continously drain the - buffer by grabbing and discarding frames and only returning the - latest frame when explicitly requested. - """ - - def __init__(self, stream: str, max_fps=10, keep_connection_open=True): - self.rtsp_url = stream - self.max_fps = max_fps - self.keep_connection_open = keep_connection_open - - self.lock = Lock() - self.run = True - - self.capture = cv2.VideoCapture(self.rtsp_url) - logger.debug(f"initialized video capture with backend={self.capture.getBackendName()}") - - if self.keep_connection_open: - self._open_connection() - self._init_drain_thread() - - def _open_connection(self): - self.capture = cv2.VideoCapture(self.rtsp_url) - if not self.capture.isOpened(): - raise ValueError( - f"Could not open RTSP stream: {self.rtsp_url}. Is the RTSP URL correct? Is the camera connected to the network?" - ) - logger.debug(f"Initialized video capture with backend={self.capture.getBackendName()}") - - def _close_connection(self): - with self.lock: - if self.capture is not None: - self.capture.release() - - def grab(self): - start = time.time() - with self.lock: - logger.debug("grabbed lock to read frame from buffer") - ret, frame = self.capture.read() # grab and decode since we want this frame - if not ret: - logger.error(f"could not read frame from {self.capture=}") - now = time.time() - logger.debug(f"read the frame in {1000*(now-start):.1f}ms") - return frame - - def _grab_implementation(self) -> np.ndarray: - if not self.keep_connection_open: - self._open_connection() - try: - return self._grab_open() - finally: - self._close_connection() - else: - return self._grab_open() - - def _grab_open(self) -> np.ndarray: - with self.lock: - ret, frame = self.capture.retrieve() if self.keep_connection_open else self.capture.read() - if not ret: - logger.error(f"Could not read frame from {self.capture}") - return frame - - def release(self) -> None: - if self.keep_connection_open: - self.run = False # to stop the buffer drain thread - self._close_connection() - - def _init_drain_thread(self): - if not self.keep_connection_open: - return # No need to drain if we're not keeping the connection open - - self.drain_rate = 1 / self.max_fps - thread = Thread(target=self._drain) - thread.daemon = True - thread.start() - - def _drain(self): - while self.run: - with self.lock: - _ = self.capture.grab() - time.sleep(self.drain_rate) - - -class YouTubeFrameGrabber(FrameGrabber): - """grabs the most recent frame from an YouTube stream. To avoid extraneous bandwidth - this class tears down the stream between each frame grab. maximum framerate - is likely around 0.5fps in most cases. - """ - - def __init__(self, stream=None): - self.stream = stream - streams = streamlink.streams(self.stream) - if "best" not in streams: - raise ValueError("No available HLS stream for this live video.") - self.best_video = streams["best"] - - self.capture = cv2.VideoCapture(self.best_video.url) - logger.debug(f"initialized video capture with backend={self.capture.getBackendName()}") - if not self.capture.isOpened(): - raise ValueError(f"could not initially open {self.stream=}") - self.capture.release() - - def reset_stream(self): - streams = streamlink.streams(self.stream) - if "best" not in streams: - raise ValueError("No available HLS stream for this live video.") - self.best_video = streams["best"] - - self.capture = cv2.VideoCapture(self.best_video.url) - logger.debug(f"initialized video capture with backend={self.capture.getBackendName()}") - if not self.capture.isOpened(): - raise ValueError(f"could not initially open {self.stream=}") - self.capture.release() + logger.debug(f"keep_connection_open is not supported for stream type {stream_type}") - def grab(self): - start = time.time() - self.capture = cv2.VideoCapture(self.best_video.url) - ret, frame = self.capture.read() # grab and decode since we want this frame - if not ret: - logger.error(f"could not read frame from {self.capture=}. attempting to reset stream") - self.reset_stream() - self.capture = cv2.VideoCapture(self.best_video.url) - ret, frame = self.capture.read() - if not ret: - logger.error(f"failed to effectively reset stream {self.stream=} / {self.best_video.url=}") - now = time.time() - logger.debug(f"read the frame in {1000*(now-start):.1f}ms") - self.capture.release() - return frame + return options -class ImageURLFrameGrabber(FrameGrabber): - """grabs the current image at a single URL. - NOTE: if image is expected to be refreshed or change with a particular frequency, - it is up to the user of the class to call the `grab` method with that frequency - """ +def framegrabber_factory( # noqa: PLR0913 + stream: str | int, + stream_type: StreamType | None = None, + height: int | None = None, + width: int | None = None, + max_fps: int | None = None, + keep_connection_open: bool | None = None, +) -> FrameGrabber: + if stream_type is None: + stream_type = _infer_stream_type(stream) - def __init__(self, url=None, **kwargs): - self.url = url + grabber_config: dict[str, Any] = {"input_type": stream_type} + stream_id = _stream_to_id(stream, stream_type) + if stream_id is not None: + grabber_config["id"] = stream_id - def grab(self): - start = time.time() - try: - req = urllib.request.urlopen(self.url) - response = req.read() - arr = np.asarray(bytearray(response), dtype=np.uint8) - frame = cv2.imdecode(arr, -1) # 'Load it as it is' - except Exception as e: - logger.error(f"could not grab frame from {self.url}: {str(e)}") - frame = None - now = time.time() - elapsed = now - start - logger.info(f"read image from URL {self.url} into frame in {elapsed}s") + grabber_options = _configure_options(stream_type, height, width, max_fps, keep_connection_open) + if len(grabber_options) > 0: + grabber_config["options"] = grabber_options - return frame + grabber = FrameGrabber.create_grabber(config=grabber_config) + return grabber diff --git a/src/stream/grabber2.py b/src/stream/grabber2.py deleted file mode 100644 index 432973f..0000000 --- a/src/stream/grabber2.py +++ /dev/null @@ -1,113 +0,0 @@ -import logging -from enum import StrEnum -from pathlib import Path -from typing import Any - -from framegrab import FrameGrabber - -logger = logging.getLogger("groundlight.stream") - - -class StreamType(StrEnum): - GENERIC_USB = "generic_usb" - RTSP = "rtsp" - HLS = "hls" - YOUTUBE_LIVE = "youtube_live" - DIRECTORY = "directory" # Not implemented yet - FILE = "file" # Not implemented yet - IMAGE_URL = "image_url" # Not implemented yet - - -def _infer_stream_type(stream: str | int) -> StreamType: - """Infer the stream type from the stream source.""" - if isinstance(stream, int): - return StreamType.GENERIC_USB - - if not isinstance(stream, str): - raise TypeError(f"Stream must be a string or int, got {type(stream)}") - - logger.debug(f"Stream {stream} is not an int, inferring type from string") - - # Check URL patterns - if stream.startswith("rtsp://"): - return StreamType.RTSP - if stream.startswith("http"): - if stream.endswith(".m3u8"): - return StreamType.HLS - if stream.startswith("https://www.youtube.com"): - return StreamType.YOUTUBE_LIVE - raise NotImplementedError("Image URL stream type is not supported yet") - - # Check file patterns - if "*" in stream: - raise NotImplementedError("Directory stream type is not supported yet") - if Path(stream).is_file(): - raise NotImplementedError("File stream type is not supported yet") - - raise ValueError(f"Could not infer stream type from: {stream}") - - -def _stream_to_id(stream: str | int, stream_type: StreamType) -> dict[str, str | int] | None: - if stream_type == StreamType.YOUTUBE_LIVE: - return {"youtube_url": stream} - elif stream_type == StreamType.RTSP: - return {"rtsp_url": stream} - elif stream_type == StreamType.HLS: - return {"hls_url": stream} - elif stream_type == StreamType.GENERIC_USB: - return {"serial_number": stream} - return None - - -def _configure_options( - stream_type: StreamType, - height: int | None = None, - width: int | None = None, - max_fps: int | None = None, - keep_connection_open: bool | None = None, -) -> dict: - options = {} - - if height is not None: - options["resolution.height"] = height - if width is not None: - options["resolution.width"] = width - - if max_fps is not None: - if stream_type == StreamType.RTSP: - options["max_fps"] = max_fps - else: - logger.warning(f"max_fps is not supported for stream type {stream_type}") - - if keep_connection_open is not None: - if stream_type in [StreamType.RTSP, StreamType.YOUTUBE_LIVE, StreamType.HLS]: - options["keep_connection_open"] = keep_connection_open - logger.info(f"keep_connection_open set to {keep_connection_open}") - else: - logger.debug(f"keep_connection_open is not supported for stream type {stream_type}") - - return options - - -def framegrabber_factory( # noqa: PLR0913 - stream: str | int, - stream_type: StreamType | None = None, - height: int | None = None, - width: int | None = None, - max_fps: int | None = None, - keep_connection_open: bool | None = None, -) -> FrameGrabber: - if stream_type is None: - stream_type = _infer_stream_type(stream) - - grabber_config: dict[str, Any] = {"input_type": stream_type} - stream_id = _stream_to_id(stream, stream_type) - if stream_id is not None: - grabber_config["id"] = stream_id - - grabber_options = _configure_options(stream_type, height, width, max_fps, keep_connection_open) - if len(grabber_options) > 0: - grabber_config["options"] = grabber_options - - grabber = FrameGrabber.create_grabber(config=grabber_config) - return grabber diff --git a/src/stream/image_processing.py b/src/stream/image_processing.py index 6a79909..25f1a99 100644 --- a/src/stream/image_processing.py +++ b/src/stream/image_processing.py @@ -10,35 +10,6 @@ logger = logging.getLogger(name="groundlight.stream") -def resize_if_needed(frame: cv2.Mat, width: int, height: int) -> cv2.Mat: - """Resize image frame while maintaining aspect ratio - - Args: - frame: OpenCV image array - width: Target width in pixels (0 to scale based on height) - height: Target height in pixels (0 to scale based on width) - """ - if (width == 0) & (height == 0): # No resize needed - return frame - - image_height, image_width, _ = frame.shape - if width > 0: - target_width = width - else: - height_proportion = height / image_height - target_width = int(image_width * height_proportion) - - if height > 0: - target_height = height - else: - width_proportion = width / image_width - target_height = int(image_height * width_proportion) - - logger.warning(f"resizing from {frame.shape=} to {target_width=}x{target_height=}") - frame = cv2.resize(frame, dsize=(target_width, target_height)) - return frame - - def crop_frame(frame: cv2.Mat, crop_region: tuple[float, float, float, float]) -> cv2.Mat: """Crop image frame to specified region diff --git a/src/stream/logging.yaml b/src/stream/logging.yaml index f56dbd1..a74a0a3 100644 --- a/src/stream/logging.yaml +++ b/src/stream/logging.yaml @@ -3,19 +3,22 @@ version: 1 handlers: console: level: DEBUG - # Change the formatter to detailed or JSON as desired. - formatter: simple + # Change the formatter to simple, detailed, or JSON as desired. + formatter: detailed class: logging.StreamHandler stream: ext://sys.stdout + formatters: # Pick the format you like simple: # A clean simple log for humans format: '%(message)s' + detailed: # A traditional informational log like a web-server might produce format: '%(asctime)s %(levelname)-8s %(name)-15s %(message)s' datefmt: '%Y-%m-%d %H:%M:%S' + json: # A full-featured, complex log - great with an enterprise structured log service class: jsonformatter.JsonFormatter @@ -32,11 +35,13 @@ formatters: "created":"created", "message":"message" } + loggers: groundlight: level: INFO handlers: [console] propagate: false + root: level: INFO handlers: [console] diff --git a/src/stream/main.py b/src/stream/main.py index 91c590a..82dd074 100644 --- a/src/stream/main.py +++ b/src/stream/main.py @@ -14,7 +14,7 @@ from framegrab import FrameGrabber, MotionDetector from groundlight import Groundlight -from stream.grabber2 import StreamType, framegrabber_factory +from stream.grabber import StreamType, framegrabber_factory from stream.image_processing import crop_frame, parse_crop_string from stream.threads import setup_workers @@ -28,18 +28,19 @@ A command-line tool that captures frames from a video source and sends them to a Groundlight detector for analysis. Supports a variety of input sources including: -- Video devices (webcams) -- Video files (mp4, etc) +- Video devices (usb cameras, webcams, etc) - RTSP streams -- YouTube videos +- YouTube Live streams +- HLS streams - Image directories +- Video files (mp4, etc) - Image URLs """ - # TODO list: -# - Remove multithreading - not needed now that the Groundlight client supports ask_async -# - Use the FrameGrabber class from the framegrab library +# - Reintroduce support for image URLs in upstream framegrab lib +# - Reintroduce support for image directories in upstream framegrab lib +# - Reintroduce support for video files in upstream framegrab lib def process_single_frame(frame: cv2.Mat, gl: Groundlight, detector: str) -> None: @@ -51,21 +52,22 @@ def process_single_frame(frame: cv2.Mat, gl: Groundlight, detector: str) -> None detector: ID of detector to query """ try: - # Prepare image + # Encode image to JPEG start = time.time() - is_success, buffer = cv2.imencode(".jpg", frame) - io_buf = io.BytesIO(buffer) # type: ignore - end = time.time() - logger.info(f"Prepared the image in {1000*(end-start):.1f}ms") + _, buffer = cv2.imencode(".jpg", frame) + io_buf = io.BytesIO(buffer) + encode_time = time.time() - start + logger.debug(f"Encoded image to JPEG in {encode_time*1000:.1f}ms") - # Submit image to Groundlight + # Submit to Groundlight start = time.time() image_query = gl.ask_async(detector=detector, image=io_buf) - end = time.time() - logger.debug(f"{image_query=}") - logger.info(f"API time for image {1000*(end-start):.1f}ms") + api_time = time.time() - start + logger.debug(f"Submitted image query via gl.ask_async() in {api_time*1000:.1f}ms") + logger.debug(f"Image query response:\n{image_query.model_dump_json(indent=2)}") + except Exception as e: - logger.error(f"Exception while processing frame : {e}", exc_info=True) + logger.error(f"Failed to process frame: {e}", exc_info=True) def validate_stream_args(args: argparse.Namespace) -> tuple[str | int, StreamType | None]: @@ -126,7 +128,7 @@ def run_capture_loop( # noqa: PLR0912 PLR0913 continue now = time.time() - logger.debug(f"Captured frame in {now-start:.3f}s, size {frame.shape}") + logger.debug(f"Grabbed frame in {now-start:.3f}s, size {frame.shape}") # Apply cropping if configured, needs to happen before motion detection if crop_region: @@ -248,7 +250,7 @@ def main(): "--keep-connection-open", action="store_true", default=False, - help="Keep connection open for low-latency frame grabbing (uses more CPU). Defaults to false.", + help="Keep connection open for low-latency frame grabbing (uses more CPU and network bandwidth). Defaults to false.", ) # Image pre-processing @@ -257,7 +259,7 @@ def main(): parser.add_argument( "-c", "--crop", - default="0,0,1,1", + default=None, help="Crop region, specified as fractions (0-1) of each dimension (e.g. '0.25,0.2,0.8,0.9').", ) diff --git a/test/test_arg_parsing.py b/test/test_arg_parsing.py index 546cf6f..2307927 100644 --- a/test/test_arg_parsing.py +++ b/test/test_arg_parsing.py @@ -17,10 +17,10 @@ def test_parse_stream_args(): assert stream_type is None # Test explicit device type - args = argparse.Namespace(stream="1", streamtype="device") + args = argparse.Namespace(stream="1", streamtype="generic_usb") stream, stream_type = validate_stream_args(args) assert stream == "1" - assert stream_type == "device" + assert stream_type == "generic_usb" # Test directory type args = argparse.Namespace(stream="*.jpg", streamtype="directory") @@ -35,10 +35,10 @@ def test_parse_stream_args(): assert stream_type == "rtsp" # Test YouTube URL - args = argparse.Namespace(stream="https://youtube.com/watch?v=123", streamtype="youtube") + args = argparse.Namespace(stream="https://youtube.com/watch?v=123", streamtype="youtube_live") stream, stream_type = validate_stream_args(args) assert stream == "https://youtube.com/watch?v=123" - assert stream_type == "youtube" + assert stream_type == "youtube_live" def test_parse_motion_args(): diff --git a/test/test_grabber_creation.py b/test/test_grabber_creation.py index 2e25e18..16b5ae1 100644 --- a/test/test_grabber_creation.py +++ b/test/test_grabber_creation.py @@ -1,7 +1,7 @@ import pytest from framegrab import FrameGrabber -from stream.grabber2 import StreamType, _configure_options, _infer_stream_type, _stream_to_id, framegrabber_factory +from stream.grabber import StreamType, _configure_options, _infer_stream_type, _stream_to_id, framegrabber_factory def test_infer_stream_type(): @@ -85,23 +85,3 @@ def test_configure_options(): # Test keep_connection_open for unsupported types opts = _configure_options(StreamType.GENERIC_USB, keep_connection_open=True) assert "keep_connection_open" not in opts - - -def test_framegrabber_factory(): - # Test USB camera creation - grabber = framegrabber_factory(0) - assert isinstance(grabber, FrameGrabber) - - # Test RTSP stream creation with options - grabber = framegrabber_factory( - "rtsp://example.com", stream_type=StreamType.RTSP, height=480, width=640, max_fps=30, keep_connection_open=True - ) - assert isinstance(grabber, FrameGrabber) - - # Test stream type inference - grabber = framegrabber_factory("rtsp://example.com") - assert isinstance(grabber, FrameGrabber) - - # Test invalid stream - with pytest.raises(ValueError): - framegrabber_factory("invalid://stream") diff --git a/test/test_image_processing.py b/test/test_image_processing.py index 6ab64ba..a9353a3 100644 --- a/test/test_image_processing.py +++ b/test/test_image_processing.py @@ -1,32 +1,7 @@ import numpy as np import pytest -from stream.image_processing import crop_frame, parse_crop_string, resize_if_needed - - -def test_resize_if_needed(): - # Create test image - test_img = np.zeros((100, 200, 3), dtype=np.uint8) - - # Test resize by width only - img_copy = test_img.copy() - out = resize_if_needed(img_copy, width=50, height=0) - assert out.shape == (25, 50, 3) - - # Test resize by height only - img_copy = test_img.copy() - out = resize_if_needed(img_copy, width=0, height=50) - assert out.shape == (50, 100, 3) - - # Test resize by both dimensions - img_copy = test_img.copy() - out = resize_if_needed(img_copy, width=50, height=25) - assert out.shape == (25, 50, 3) - - # Test no resize when both 0 - img_copy = test_img.copy() - out = resize_if_needed(img_copy, width=0, height=0) - assert out.shape == (100, 200, 3) +from stream.image_processing import crop_frame, parse_crop_string def test_crop_frame(): diff --git a/test/test_image_submission.py b/test/test_image_submission.py index 4d3692f..f5ca2c2 100644 --- a/test/test_image_submission.py +++ b/test/test_image_submission.py @@ -59,8 +59,6 @@ def test_run_capture_loop_basic(test_frame): motion_detector=None, post_motion_time=0, max_frame_interval=0, - resize_width=0, - resize_height=0, crop_region=None, ) @@ -99,8 +97,6 @@ def test_run_capture_loop_motion_detection(test_frame): motion_detector=motion_detector, post_motion_time=1.0, max_frame_interval=5.0, - resize_width=0, - resize_height=0, crop_region=None, ) @@ -131,8 +127,6 @@ def test_run_capture_loop_fps_zero(test_frame): motion_detector=None, post_motion_time=0, max_frame_interval=0, - resize_width=0, - resize_height=0, crop_region=None, ) @@ -163,8 +157,6 @@ def test_run_capture_loop_no_frame(): motion_detector=None, post_motion_time=0, max_frame_interval=0, - resize_width=0, - resize_height=0, crop_region=None, ) diff --git a/uv.lock b/uv.lock index 39f14c9..3e8082f 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -requires-python = ">=3.10" +requires-python = ">=3.11" resolution-markers = [ "python_full_version < '3.9' and platform_machine == 'arm64' and platform_system == 'Darwin'", "python_full_version < '3.9' and platform_machine == 'aarch64' and platform_system == 'Linux'", @@ -98,18 +98,6 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/fc/97/c783634659c2920c3fc70419e3af40972dbaf758daa229a7d6ea6135c90d/cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824", size = 516621 } wheels = [ - { url = "https://files.pythonhosted.org/packages/90/07/f44ca684db4e4f08a3fdc6eeb9a0d15dc6883efc7b8c90357fdbf74e186c/cffi-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14", size = 182191 }, - { url = "https://files.pythonhosted.org/packages/08/fd/cc2fedbd887223f9f5d170c96e57cbf655df9831a6546c1727ae13fa977a/cffi-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67", size = 178592 }, - { url = "https://files.pythonhosted.org/packages/de/cc/4635c320081c78d6ffc2cab0a76025b691a91204f4aa317d568ff9280a2d/cffi-1.17.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382", size = 426024 }, - { url = "https://files.pythonhosted.org/packages/b6/7b/3b2b250f3aab91abe5f8a51ada1b717935fdaec53f790ad4100fe2ec64d1/cffi-1.17.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702", size = 448188 }, - { url = "https://files.pythonhosted.org/packages/d3/48/1b9283ebbf0ec065148d8de05d647a986c5f22586b18120020452fff8f5d/cffi-1.17.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3", size = 455571 }, - { url = "https://files.pythonhosted.org/packages/40/87/3b8452525437b40f39ca7ff70276679772ee7e8b394934ff60e63b7b090c/cffi-1.17.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5da5719280082ac6bd9aa7becb3938dc9f9cbd57fac7d2871717b1feb0902ab6", size = 436687 }, - { url = "https://files.pythonhosted.org/packages/8d/fb/4da72871d177d63649ac449aec2e8a29efe0274035880c7af59101ca2232/cffi-1.17.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17", size = 446211 }, - { url = "https://files.pythonhosted.org/packages/ab/a0/62f00bcb411332106c02b663b26f3545a9ef136f80d5df746c05878f8c4b/cffi-1.17.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8", size = 461325 }, - { url = "https://files.pythonhosted.org/packages/36/83/76127035ed2e7e27b0787604d99da630ac3123bfb02d8e80c633f218a11d/cffi-1.17.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e", size = 438784 }, - { url = "https://files.pythonhosted.org/packages/21/81/a6cd025db2f08ac88b901b745c163d884641909641f9b826e8cb87645942/cffi-1.17.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be", size = 461564 }, - { url = "https://files.pythonhosted.org/packages/f8/fe/4d41c2f200c4a457933dbd98d3cf4e911870877bd94d9656cc0fcb390681/cffi-1.17.1-cp310-cp310-win32.whl", hash = "sha256:c9c3d058ebabb74db66e431095118094d06abf53284d9c81f27300d0e0d8bc7c", size = 171804 }, - { url = "https://files.pythonhosted.org/packages/d1/b6/0b0f5ab93b0df4acc49cae758c81fe4e5ef26c3ae2e10cc69249dfd8b3ab/cffi-1.17.1-cp310-cp310-win_amd64.whl", hash = "sha256:0f048dcf80db46f0098ccac01132761580d28e28bc0f78ae0d58048063317e15", size = 181299 }, { url = "https://files.pythonhosted.org/packages/6b/f4/927e3a8899e52a27fa57a48607ff7dc91a9ebe97399b357b85a0c7892e00/cffi-1.17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401", size = 182264 }, { url = "https://files.pythonhosted.org/packages/6c/f5/6c3a8efe5f503175aaddcbea6ad0d2c96dad6f5abb205750d1b3df44ef29/cffi-1.17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf", size = 178651 }, { url = "https://files.pythonhosted.org/packages/94/dd/a3f0118e688d1b1a57553da23b16bdade96d2f9bcda4d32e7d2838047ff7/cffi-1.17.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4", size = 445259 }, @@ -152,21 +140,6 @@ version = "3.4.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/f2/4f/e1808dc01273379acc506d18f1504eb2d299bd4131743b9fc54d7be4df1e/charset_normalizer-3.4.0.tar.gz", hash = "sha256:223217c3d4f82c3ac5e29032b3f1c2eb0fb591b72161f86d93f5719079dae93e", size = 106620 } wheels = [ - { url = "https://files.pythonhosted.org/packages/69/8b/825cc84cf13a28bfbcba7c416ec22bf85a9584971be15b21dd8300c65b7f/charset_normalizer-3.4.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:4f9fc98dad6c2eaa32fc3af1417d95b5e3d08aff968df0cd320066def971f9a6", size = 196363 }, - { url = "https://files.pythonhosted.org/packages/23/81/d7eef6a99e42c77f444fdd7bc894b0ceca6c3a95c51239e74a722039521c/charset_normalizer-3.4.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0de7b687289d3c1b3e8660d0741874abe7888100efe14bd0f9fd7141bcbda92b", size = 125639 }, - { url = "https://files.pythonhosted.org/packages/21/67/b4564d81f48042f520c948abac7079356e94b30cb8ffb22e747532cf469d/charset_normalizer-3.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5ed2e36c3e9b4f21dd9422f6893dec0abf2cca553af509b10cd630f878d3eb99", size = 120451 }, - { url = "https://files.pythonhosted.org/packages/c2/72/12a7f0943dd71fb5b4e7b55c41327ac0a1663046a868ee4d0d8e9c369b85/charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40d3ff7fc90b98c637bda91c89d51264a3dcf210cade3a2c6f838c7268d7a4ca", size = 140041 }, - { url = "https://files.pythonhosted.org/packages/67/56/fa28c2c3e31217c4c52158537a2cf5d98a6c1e89d31faf476c89391cd16b/charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1110e22af8ca26b90bd6364fe4c763329b0ebf1ee213ba32b68c73de5752323d", size = 150333 }, - { url = "https://files.pythonhosted.org/packages/f9/d2/466a9be1f32d89eb1554cf84073a5ed9262047acee1ab39cbaefc19635d2/charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86f4e8cca779080f66ff4f191a685ced73d2f72d50216f7112185dc02b90b9b7", size = 142921 }, - { url = "https://files.pythonhosted.org/packages/f8/01/344ec40cf5d85c1da3c1f57566c59e0c9b56bcc5566c08804a95a6cc8257/charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f683ddc7eedd742e2889d2bfb96d69573fde1d92fcb811979cdb7165bb9c7d3", size = 144785 }, - { url = "https://files.pythonhosted.org/packages/73/8b/2102692cb6d7e9f03b9a33a710e0164cadfce312872e3efc7cfe22ed26b4/charset_normalizer-3.4.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:27623ba66c183eca01bf9ff833875b459cad267aeeb044477fedac35e19ba907", size = 146631 }, - { url = "https://files.pythonhosted.org/packages/d8/96/cc2c1b5d994119ce9f088a9a0c3ebd489d360a2eb058e2c8049f27092847/charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f606a1881d2663630ea5b8ce2efe2111740df4b687bd78b34a8131baa007f79b", size = 140867 }, - { url = "https://files.pythonhosted.org/packages/c9/27/cde291783715b8ec30a61c810d0120411844bc4c23b50189b81188b273db/charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0b309d1747110feb25d7ed6b01afdec269c647d382c857ef4663bbe6ad95a912", size = 149273 }, - { url = "https://files.pythonhosted.org/packages/3a/a4/8633b0fc1a2d1834d5393dafecce4a1cc56727bfd82b4dc18fc92f0d3cc3/charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:136815f06a3ae311fae551c3df1f998a1ebd01ddd424aa5603a4336997629e95", size = 152437 }, - { url = "https://files.pythonhosted.org/packages/64/ea/69af161062166b5975ccbb0961fd2384853190c70786f288684490913bf5/charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:14215b71a762336254351b00ec720a8e85cada43b987da5a042e4ce3e82bd68e", size = 150087 }, - { url = "https://files.pythonhosted.org/packages/3b/fd/e60a9d9fd967f4ad5a92810138192f825d77b4fa2a557990fd575a47695b/charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:79983512b108e4a164b9c8d34de3992f76d48cadc9554c9e60b43f308988aabe", size = 145142 }, - { url = "https://files.pythonhosted.org/packages/6d/02/8cb0988a1e49ac9ce2eed1e07b77ff118f2923e9ebd0ede41ba85f2dcb04/charset_normalizer-3.4.0-cp310-cp310-win32.whl", hash = "sha256:c94057af19bc953643a33581844649a7fdab902624d2eb739738a30e2b3e60fc", size = 94701 }, - { url = "https://files.pythonhosted.org/packages/d6/20/f1d4670a8a723c46be695dff449d86d6092916f9e99c53051954ee33a1bc/charset_normalizer-3.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:55f56e2ebd4e3bc50442fbc0888c9d8c94e4e06a933804e2af3e89e2f9c1c749", size = 102191 }, { url = "https://files.pythonhosted.org/packages/9c/61/73589dcc7a719582bf56aae309b6103d2762b526bffe189d635a7fcfd998/charset_normalizer-3.4.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0d99dd8ff461990f12d6e42c7347fd9ab2532fb70e9621ba520f9e8637161d7c", size = 193339 }, { url = "https://files.pythonhosted.org/packages/77/d5/8c982d58144de49f59571f940e329ad6e8615e1e82ef84584c5eeb5e1d72/charset_normalizer-3.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c57516e58fd17d03ebe67e181a4e4e2ccab1168f8c2976c6a334d4f819fe5944", size = 124366 }, { url = "https://files.pythonhosted.org/packages/bf/19/411a64f01ee971bed3231111b69eb56f9331a769072de479eae7de52296d/charset_normalizer-3.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6dba5d19c4dfab08e58d5b36304b3f92f3bd5d42c1a3fa37b5ba5cdf6dfcbcee", size = 118874 }, @@ -236,18 +209,9 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, ] -[[package]] -name = "exceptiongroup" -version = "1.2.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/09/35/2495c4ac46b980e4ca1f6ad6db102322ef3ad2410b79fdde159a4b0f3b92/exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc", size = 28883 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/02/cc/b7e31358aac6ed1ef2bb790a9746ac2c69bcb3c8588b41616914eb106eaf/exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b", size = 16453 }, -] - [[package]] name = "framegrab" -version = "0.7.0" +version = "0.8.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "ascii-magic" }, @@ -259,9 +223,9 @@ dependencies = [ { name = "pyyaml" }, { name = "wsdiscovery" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a1/1c/6b744eb5c4f3ebbb1aa2fca56f229315e4398bd6380c885e89ac5a8d0030/framegrab-0.7.0.tar.gz", hash = "sha256:b60974694fd803bfa4dd8bd2bcab96e4bfec072939d1de5200383346f553d383", size = 25975 } +sdist = { url = "https://files.pythonhosted.org/packages/ac/75/fecc3baadb93bdb0f5033d8bb13a91a7217da0555dc1ff2a2f1eda5746de/framegrab-0.8.0.tar.gz", hash = "sha256:e1553f4985eb72d949aaf5a906d4e3078ada40b6a17e200bd32bff01430e136d", size = 28168 } wheels = [ - { url = "https://files.pythonhosted.org/packages/95/05/5388994fa7f4d94824d802f26b6f67958d3c2a6c248898838abfed24b392/framegrab-0.7.0-py3-none-any.whl", hash = "sha256:0d42d936bc8e48b7b58f9f07a0504667a943a3025ed930459c85a31edcf1295e", size = 26434 }, + { url = "https://files.pythonhosted.org/packages/18/7a/e7c0cc740e256b71e95b0906980b84fc1fdd4efbe66c718ab86587b55928/framegrab-0.8.0-py3-none-any.whl", hash = "sha256:725fabc8b5f98a971ffdba0c47810234442837bf6916edb708c8db6287401d01", size = 27979 }, ] [[package]] @@ -270,14 +234,6 @@ version = "2.4.6" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/bb/59/19eb300ba28e7547538bdf603f1c6c34793240a90e1a7b61b65d8517e35e/frozendict-2.4.6.tar.gz", hash = "sha256:df7cd16470fbd26fc4969a208efadc46319334eb97def1ddf48919b351192b8e", size = 316416 } wheels = [ - { url = "https://files.pythonhosted.org/packages/a6/7f/e80cdbe0db930b2ba9d46ca35a41b0150156da16dfb79edcc05642690c3b/frozendict-2.4.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c3a05c0a50cab96b4bb0ea25aa752efbfceed5ccb24c007612bc63e51299336f", size = 37927 }, - { url = "https://files.pythonhosted.org/packages/29/98/27e145ff7e8e63caa95fb8ee4fc56c68acb208bef01a89c3678a66f9a34d/frozendict-2.4.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f5b94d5b07c00986f9e37a38dd83c13f5fe3bf3f1ccc8e88edea8fe15d6cd88c", size = 37945 }, - { url = "https://files.pythonhosted.org/packages/ac/f1/a10be024a9d53441c997b3661ea80ecba6e3130adc53812a4b95b607cdd1/frozendict-2.4.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f4c789fd70879ccb6289a603cdebdc4953e7e5dea047d30c1b180529b28257b5", size = 117656 }, - { url = "https://files.pythonhosted.org/packages/46/a6/34c760975e6f1cb4db59a990d58dcf22287e10241c851804670c74c6a27a/frozendict-2.4.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:da6a10164c8a50b34b9ab508a9420df38f4edf286b9ca7b7df8a91767baecb34", size = 117444 }, - { url = "https://files.pythonhosted.org/packages/62/dd/64bddd1ffa9617f50e7e63656b2a7ad7f0a46c86b5f4a3d2c714d0006277/frozendict-2.4.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:9a8a43036754a941601635ea9c788ebd7a7efbed2becba01b54a887b41b175b9", size = 116801 }, - { url = "https://files.pythonhosted.org/packages/45/ae/af06a8bde1947277aad895c2f26c3b8b8b6ee9c0c2ad988fb58a9d1dde3f/frozendict-2.4.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c9905dcf7aa659e6a11b8051114c9fa76dfde3a6e50e6dc129d5aece75b449a2", size = 117329 }, - { url = "https://files.pythonhosted.org/packages/d2/df/be3fa0457ff661301228f4c59c630699568c8ed9b5480f113b3eea7d0cb3/frozendict-2.4.6-cp310-cp310-win_amd64.whl", hash = "sha256:323f1b674a2cc18f86ab81698e22aba8145d7a755e0ac2cccf142ee2db58620d", size = 37522 }, - { url = "https://files.pythonhosted.org/packages/4a/6f/c22e0266b4c85f58b4613fec024e040e93753880527bf92b0c1bc228c27c/frozendict-2.4.6-cp310-cp310-win_arm64.whl", hash = "sha256:eabd21d8e5db0c58b60d26b4bb9839cac13132e88277e1376970172a85ee04b3", size = 34056 }, { url = "https://files.pythonhosted.org/packages/04/13/d9839089b900fa7b479cce495d62110cddc4bd5630a04d8469916c0e79c5/frozendict-2.4.6-py311-none-any.whl", hash = "sha256:d065db6a44db2e2375c23eac816f1a022feb2fa98cbb50df44a9e83700accbea", size = 16148 }, { url = "https://files.pythonhosted.org/packages/ba/d0/d482c39cee2ab2978a892558cf130681d4574ea208e162da8958b31e9250/frozendict-2.4.6-py312-none-any.whl", hash = "sha256:49344abe90fb75f0f9fdefe6d4ef6d4894e640fadab71f11009d52ad97f370b9", size = 16146 }, { url = "https://files.pythonhosted.org/packages/a5/8e/b6bf6a0de482d7d7d7a2aaac8fdc4a4d0bb24a809f5ddd422aa7060eb3d2/frozendict-2.4.6-py313-none-any.whl", hash = "sha256:7134a2bb95d4a16556bb5f2b9736dceb6ea848fa5b6f3f6c2d6dba93b44b4757", size = 16146 }, @@ -285,7 +241,7 @@ wheels = [ [[package]] name = "groundlight" -version = "0.18.4" +version = "0.20.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, @@ -297,9 +253,9 @@ dependencies = [ { name = "typer" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a1/dc/450bf138a761c1e2204240104080de28ec784421cfbbfceb6bbb8c236abb/groundlight-0.18.4.tar.gz", hash = "sha256:0e60eac967ce2b7140f2668803a465b58d43c9e48d7275c9f6451d8746beaaab", size = 90113 } +sdist = { url = "https://files.pythonhosted.org/packages/95/a6/e7464fc64bd90c49c9550aca5cde2c939110eb02454f72259a437c4b6667/groundlight-0.20.0.tar.gz", hash = "sha256:2be9e0399675d0eead7b45bf2bac5067b7d9b01391323a0cdb2b95a55d764f23", size = 98146 } wheels = [ - { url = "https://files.pythonhosted.org/packages/15/85/f2c4bbf614a17a5ab02b49bffe46b75735a870fa79350fd51567e02be7d4/groundlight-0.18.4-py3-none-any.whl", hash = "sha256:4d6b3a60d3aedcdce2d1ff712fc6d3f7e5f0a0244c9247c24d717327576b7741", size = 243643 }, + { url = "https://files.pythonhosted.org/packages/99/82/3d8a3aa61767abc4890be31c6bf8c784a79891bf04066b35fa99458af40f/groundlight-0.20.0-py3-none-any.whl", hash = "sha256:1a5ee7f6c0b45d0293a0cc6437dcf68f7681b753d4c6709179fb5d9715dbace0", size = 253738 }, ] [[package]] @@ -356,23 +312,6 @@ version = "5.3.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/e7/6b/20c3a4b24751377aaa6307eb230b66701024012c29dd374999cc92983269/lxml-5.3.0.tar.gz", hash = "sha256:4e109ca30d1edec1ac60cdbe341905dc3b8f55b16855e03a54aaf59e51ec8c6f", size = 3679318 } wheels = [ - { url = "https://files.pythonhosted.org/packages/a1/ce/2789e39eddf2b13fac29878bfa465f0910eb6b0096e29090e5176bc8cf43/lxml-5.3.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:dd36439be765e2dde7660212b5275641edbc813e7b24668831a5c8ac91180656", size = 8124570 }, - { url = "https://files.pythonhosted.org/packages/24/a8/f4010166a25d41715527129af2675981a50d3bbf7df09c5d9ab8ca24fbf9/lxml-5.3.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ae5fe5c4b525aa82b8076c1a59d642c17b6e8739ecf852522c6321852178119d", size = 4413042 }, - { url = "https://files.pythonhosted.org/packages/41/a4/7e45756cecdd7577ddf67a68b69c1db0f5ddbf0c9f65021ee769165ffc5a/lxml-5.3.0-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:501d0d7e26b4d261fca8132854d845e4988097611ba2531408ec91cf3fd9d20a", size = 5139213 }, - { url = "https://files.pythonhosted.org/packages/02/e2/ecf845b12323c92748077e1818b64e8b4dba509a4cb12920b3762ebe7552/lxml-5.3.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb66442c2546446944437df74379e9cf9e9db353e61301d1a0e26482f43f0dd8", size = 4838814 }, - { url = "https://files.pythonhosted.org/packages/12/91/619f9fb72cf75e9ceb8700706f7276f23995f6ad757e6d400fbe35ca4990/lxml-5.3.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9e41506fec7a7f9405b14aa2d5c8abbb4dbbd09d88f9496958b6d00cb4d45330", size = 5425084 }, - { url = "https://files.pythonhosted.org/packages/25/3b/162a85a8f0fd2a3032ec3f936636911c6e9523a8e263fffcfd581ce98b54/lxml-5.3.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f7d4a670107d75dfe5ad080bed6c341d18c4442f9378c9f58e5851e86eb79965", size = 4875993 }, - { url = "https://files.pythonhosted.org/packages/43/af/dd3f58cc7d946da6ae42909629a2b1d5dd2d1b583334d4af9396697d6863/lxml-5.3.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:41ce1f1e2c7755abfc7e759dc34d7d05fd221723ff822947132dc934d122fe22", size = 5012462 }, - { url = "https://files.pythonhosted.org/packages/69/c1/5ea46b2d4c98f5bf5c83fffab8a0ad293c9bc74df9ecfbafef10f77f7201/lxml-5.3.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:44264ecae91b30e5633013fb66f6ddd05c006d3e0e884f75ce0b4755b3e3847b", size = 4815288 }, - { url = "https://files.pythonhosted.org/packages/1d/51/a0acca077ad35da458f4d3f729ef98effd2b90f003440d35fc36323f8ae6/lxml-5.3.0-cp310-cp310-manylinux_2_28_ppc64le.whl", hash = "sha256:3c174dc350d3ec52deb77f2faf05c439331d6ed5e702fc247ccb4e6b62d884b7", size = 5472435 }, - { url = "https://files.pythonhosted.org/packages/4d/6b/0989c9368986961a6b0f55b46c80404c4b758417acdb6d87bfc3bd5f4967/lxml-5.3.0-cp310-cp310-manylinux_2_28_s390x.whl", hash = "sha256:2dfab5fa6a28a0b60a20638dc48e6343c02ea9933e3279ccb132f555a62323d8", size = 4976354 }, - { url = "https://files.pythonhosted.org/packages/05/9e/87492d03ff604fbf656ed2bf3e2e8d28f5d58ea1f00ff27ac27b06509079/lxml-5.3.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:b1c8c20847b9f34e98080da785bb2336ea982e7f913eed5809e5a3c872900f32", size = 5029973 }, - { url = "https://files.pythonhosted.org/packages/f9/cc/9ae1baf5472af88e19e2c454b3710c1be9ecafb20eb474eeabcd88a055d2/lxml-5.3.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2c86bf781b12ba417f64f3422cfc302523ac9cd1d8ae8c0f92a1c66e56ef2e86", size = 4888837 }, - { url = "https://files.pythonhosted.org/packages/d2/10/5594ffaec8c120d75b17e3ad23439b740a51549a9b5fd7484b2179adfe8f/lxml-5.3.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:c162b216070f280fa7da844531169be0baf9ccb17263cf5a8bf876fcd3117fa5", size = 5530555 }, - { url = "https://files.pythonhosted.org/packages/ea/9b/de17f05377c8833343b629905571fb06cff2028f15a6f58ae2267662e341/lxml-5.3.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:36aef61a1678cb778097b4a6eeae96a69875d51d1e8f4d4b491ab3cfb54b5a03", size = 5405314 }, - { url = "https://files.pythonhosted.org/packages/8a/b4/227be0f1f3cca8255925985164c3838b8b36e441ff0cc10c1d3c6bdba031/lxml-5.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f65e5120863c2b266dbcc927b306c5b78e502c71edf3295dfcb9501ec96e5fc7", size = 5079303 }, - { url = "https://files.pythonhosted.org/packages/5c/ee/19abcebb7fc40319bb71cd6adefa1ad94d09b5660228715854d6cc420713/lxml-5.3.0-cp310-cp310-win32.whl", hash = "sha256:ef0c1fe22171dd7c7c27147f2e9c3e86f8bdf473fed75f16b0c2e84a5030ce80", size = 3475126 }, - { url = "https://files.pythonhosted.org/packages/a1/35/183d32551447e280032b2331738cd850da435a42f850b71ebeaab42c1313/lxml-5.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:052d99051e77a4f3e8482c65014cf6372e61b0a6f4fe9edb98503bb5364cfee3", size = 3805065 }, { url = "https://files.pythonhosted.org/packages/5c/a8/449faa2a3cbe6a99f8d38dcd51a3ee8844c17862841a6f769ea7c2a9cd0f/lxml-5.3.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:74bcb423462233bc5d6066e4e98b0264e7c1bed7541fff2f4e34fe6b21563c8b", size = 8141056 }, { url = "https://files.pythonhosted.org/packages/ac/8a/ae6325e994e2052de92f894363b038351c50ee38749d30cc6b6d96aaf90f/lxml-5.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a3d819eb6f9b8677f57f9664265d0a10dd6551d227afb4af2b9cd7bdc2ccbf18", size = 4425238 }, { url = "https://files.pythonhosted.org/packages/f8/fb/128dddb7f9086236bce0eeae2bfb316d138b49b159f50bc681d56c1bdd19/lxml-5.3.0-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5b8f5db71b28b8c404956ddf79575ea77aa8b1538e8b2ef9ec877945b3f46442", size = 5095197 }, @@ -424,12 +363,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b6/17/71e9984cf0570cd202ac0a1c9ed5c1b8889b0fc8dc736f5ef0ffb181c284/lxml-5.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:df5c7333167b9674aa8ae1d4008fa4bc17a313cc490b2cca27838bbdcc6bb15b", size = 5011053 }, { url = "https://files.pythonhosted.org/packages/69/68/9f7e6d3312a91e30829368c2b3217e750adef12a6f8eb10498249f4e8d72/lxml-5.3.0-cp313-cp313-win32.whl", hash = "sha256:c802e1c2ed9f0c06a65bc4ed0189d000ada8049312cfeab6ca635e39c9608957", size = 3485634 }, { url = "https://files.pythonhosted.org/packages/7d/db/214290d58ad68c587bd5d6af3d34e56830438733d0d0856c0275fde43652/lxml-5.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:406246b96d552e0503e17a1006fd27edac678b3fcc9f1be71a2f94b4ff61528d", size = 3814417 }, - { url = "https://files.pythonhosted.org/packages/99/f7/b73a431c8500565aa500e99e60b448d305eaf7c0b4c893c7c5a8a69cc595/lxml-5.3.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7b1cd427cb0d5f7393c31b7496419da594fe600e6fdc4b105a54f82405e6626c", size = 3925431 }, - { url = "https://files.pythonhosted.org/packages/db/48/4a206623c0d093d0e3b15f415ffb4345b0bdf661a3d0b15a112948c033c7/lxml-5.3.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:51806cfe0279e06ed8500ce19479d757db42a30fd509940b1701be9c86a5ff9a", size = 4216683 }, - { url = "https://files.pythonhosted.org/packages/54/47/577820c45dd954523ae8453b632d91e76da94ca6d9ee40d8c98dd86f916b/lxml-5.3.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ee70d08fd60c9565ba8190f41a46a54096afa0eeb8f76bd66f2c25d3b1b83005", size = 4326732 }, - { url = "https://files.pythonhosted.org/packages/68/de/96cb6d3269bc994b4f5ede8ca7bf0840f5de0a278bc6e50cb317ff71cafa/lxml-5.3.0-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:8dc2c0395bea8254d8daebc76dcf8eb3a95ec2a46fa6fae5eaccee366bfe02ce", size = 4218377 }, - { url = "https://files.pythonhosted.org/packages/a5/43/19b1ef6cbffa4244a217f95cc5f41a6cb4720fed33510a49670b03c5f1a0/lxml-5.3.0-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:6ba0d3dcac281aad8a0e5b14c7ed6f9fa89c8612b47939fc94f80b16e2e9bc83", size = 4351237 }, - { url = "https://files.pythonhosted.org/packages/ba/b2/6a22fb5c0885da3b00e116aee81f0b829ec9ac8f736cd414b4a09413fc7d/lxml-5.3.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:6e91cf736959057f7aac7adfc83481e03615a8e8dd5758aa1d95ea69e8931dba", size = 3487557 }, ] [[package]] @@ -507,12 +440,6 @@ resolution-markers = [ ] sdist = { url = "https://files.pythonhosted.org/packages/a4/9b/027bec52c633f6556dba6b722d9a0befb40498b9ceddd29cbe67a45a127c/numpy-1.24.4.tar.gz", hash = "sha256:80f5e3a4e498641401868df4208b74581206afbee7cf7b8329daae82676d9463", size = 10911229 } wheels = [ - { url = "https://files.pythonhosted.org/packages/6b/80/6cdfb3e275d95155a34659163b83c09e3a3ff9f1456880bec6cc63d71083/numpy-1.24.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c0bfb52d2169d58c1cdb8cc1f16989101639b34c7d3ce60ed70b19c63eba0b64", size = 19789140 }, - { url = "https://files.pythonhosted.org/packages/64/5f/3f01d753e2175cfade1013eea08db99ba1ee4bdb147ebcf3623b75d12aa7/numpy-1.24.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ed094d4f0c177b1b8e7aa9cba7d6ceed51c0e569a5318ac0ca9a090680a6a1b1", size = 13854297 }, - { url = "https://files.pythonhosted.org/packages/5a/b3/2f9c21d799fa07053ffa151faccdceeb69beec5a010576b8991f614021f7/numpy-1.24.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:79fc682a374c4a8ed08b331bef9c5f582585d1048fa6d80bc6c35bc384eee9b4", size = 13995611 }, - { url = "https://files.pythonhosted.org/packages/10/be/ae5bf4737cb79ba437879915791f6f26d92583c738d7d960ad94e5c36adf/numpy-1.24.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7ffe43c74893dbf38c2b0a1f5428760a1a9c98285553c89e12d70a96a7f3a4d6", size = 17282357 }, - { url = "https://files.pythonhosted.org/packages/c0/64/908c1087be6285f40e4b3e79454552a701664a079321cff519d8c7051d06/numpy-1.24.4-cp310-cp310-win32.whl", hash = "sha256:4c21decb6ea94057331e111a5bed9a79d335658c27ce2adb580fb4d54f2ad9bc", size = 12429222 }, - { url = "https://files.pythonhosted.org/packages/22/55/3d5a7c1142e0d9329ad27cece17933b0e2ab4e54ddc5c1861fbfeb3f7693/numpy-1.24.4-cp310-cp310-win_amd64.whl", hash = "sha256:b4bea75e47d9586d31e892a7401f76e909712a0fd510f58f5337bea9572c571e", size = 14841514 }, { url = "https://files.pythonhosted.org/packages/a9/cc/5ed2280a27e5dab12994c884f1f4d8c3bd4d885d02ae9e52a9d213a6a5e2/numpy-1.24.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f136bab9c2cfd8da131132c2cf6cc27331dd6fae65f95f69dcd4ae3c3639c810", size = 19775508 }, { url = "https://files.pythonhosted.org/packages/c0/bc/77635c657a3668cf652806210b8662e1aff84b818a55ba88257abf6637a8/numpy-1.24.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e2926dac25b313635e4d6cf4dc4e51c8c0ebfed60b801c799ffc4c32bf3d1254", size = 13840033 }, { url = "https://files.pythonhosted.org/packages/a7/4c/96cdaa34f54c05e97c1c50f39f98d608f96f0677a6589e64e53104e22904/numpy-1.24.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:222e40d0e2548690405b0b3c7b21d1169117391c2e82c378467ef9ab4c8f0da7", size = 13991951 }, @@ -535,14 +462,6 @@ resolution-markers = [ ] sdist = { url = "https://files.pythonhosted.org/packages/65/6e/09db70a523a96d25e115e71cc56a6f9031e7b8cd166c1ac8438307c14058/numpy-1.26.4.tar.gz", hash = "sha256:2a02aba9ed12e4ac4eb3ea9421c420301a0c6460d9830d74a9df87efa4912010", size = 15786129 } wheels = [ - { url = "https://files.pythonhosted.org/packages/a7/94/ace0fdea5241a27d13543ee117cbc65868e82213fb31a8eb7fe9ff23f313/numpy-1.26.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9ff0f4f29c51e2803569d7a51c2304de5554655a60c5d776e35b4a41413830d0", size = 20631468 }, - { url = "https://files.pythonhosted.org/packages/20/f7/b24208eba89f9d1b58c1668bc6c8c4fd472b20c45573cb767f59d49fb0f6/numpy-1.26.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2e4ee3380d6de9c9ec04745830fd9e2eccb3e6cf790d39d7b98ffd19b0dd754a", size = 13966411 }, - { url = "https://files.pythonhosted.org/packages/fc/a5/4beee6488160798683eed5bdb7eead455892c3b4e1f78d79d8d3f3b084ac/numpy-1.26.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d209d8969599b27ad20994c8e41936ee0964e6da07478d6c35016bc386b66ad4", size = 14219016 }, - { url = "https://files.pythonhosted.org/packages/4b/d7/ecf66c1cd12dc28b4040b15ab4d17b773b87fa9d29ca16125de01adb36cd/numpy-1.26.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ffa75af20b44f8dba823498024771d5ac50620e6915abac414251bd971b4529f", size = 18240889 }, - { url = "https://files.pythonhosted.org/packages/24/03/6f229fe3187546435c4f6f89f6d26c129d4f5bed40552899fcf1f0bf9e50/numpy-1.26.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:62b8e4b1e28009ef2846b4c7852046736bab361f7aeadeb6a5b89ebec3c7055a", size = 13876746 }, - { url = "https://files.pythonhosted.org/packages/39/fe/39ada9b094f01f5a35486577c848fe274e374bbf8d8f472e1423a0bbd26d/numpy-1.26.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:a4abb4f9001ad2858e7ac189089c42178fcce737e4169dc61321660f1a96c7d2", size = 18078620 }, - { url = "https://files.pythonhosted.org/packages/d5/ef/6ad11d51197aad206a9ad2286dc1aac6a378059e06e8cf22cd08ed4f20dc/numpy-1.26.4-cp310-cp310-win32.whl", hash = "sha256:bfe25acf8b437eb2a8b2d49d443800a5f18508cd811fea3181723922a8a82b07", size = 5972659 }, - { url = "https://files.pythonhosted.org/packages/19/77/538f202862b9183f54108557bfda67e17603fc560c384559e769321c9d92/numpy-1.26.4-cp310-cp310-win_amd64.whl", hash = "sha256:b97fe8060236edf3662adfc2c633f56a08ae30560c56310562cb4f95500022d5", size = 15808905 }, { url = "https://files.pythonhosted.org/packages/11/57/baae43d14fe163fa0e4c47f307b6b2511ab8d7d30177c491960504252053/numpy-1.26.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4c66707fabe114439db9068ee468c26bbdf909cac0fb58686a42a24de1760c71", size = 20630554 }, { url = "https://files.pythonhosted.org/packages/1a/2e/151484f49fd03944c4a3ad9c418ed193cfd02724e138ac8a9505d056c582/numpy-1.26.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:edd8b5fe47dab091176d21bb6de568acdd906d1887a4584a15a9a96a1dca06ef", size = 13997127 }, { url = "https://files.pythonhosted.org/packages/79/ae/7e5b85136806f9dadf4878bf73cf223fe5c2636818ba3ab1c585d0403164/numpy-1.26.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7ab55401287bfec946ced39700c053796e7cc0e3acbef09993a9ad2adba6ca6e", size = 14222994 }, @@ -629,61 +548,51 @@ wheels = [ [[package]] name = "pillow" -version = "10.4.0" +version = "11.0.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cd/74/ad3d526f3bf7b6d3f408b73fde271ec69dfac8b81341a318ce825f2b3812/pillow-10.4.0.tar.gz", hash = "sha256:166c1cd4d24309b30d61f79f4a9114b7b2313d7450912277855ff5dfd7cd4a06", size = 46555059 } +sdist = { url = "https://files.pythonhosted.org/packages/a5/26/0d95c04c868f6bdb0c447e3ee2de5564411845e36a858cfd63766bc7b563/pillow-11.0.0.tar.gz", hash = "sha256:72bacbaf24ac003fea9bff9837d1eedb6088758d41e100c1552930151f677739", size = 46737780 } wheels = [ - { url = "https://files.pythonhosted.org/packages/0e/69/a31cccd538ca0b5272be2a38347f8839b97a14be104ea08b0db92f749c74/pillow-10.4.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:4d9667937cfa347525b319ae34375c37b9ee6b525440f3ef48542fcf66f2731e", size = 3509271 }, - { url = "https://files.pythonhosted.org/packages/9a/9e/4143b907be8ea0bce215f2ae4f7480027473f8b61fcedfda9d851082a5d2/pillow-10.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:543f3dc61c18dafb755773efc89aae60d06b6596a63914107f75459cf984164d", size = 3375658 }, - { url = "https://files.pythonhosted.org/packages/8a/25/1fc45761955f9359b1169aa75e241551e74ac01a09f487adaaf4c3472d11/pillow-10.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7928ecbf1ece13956b95d9cbcfc77137652b02763ba384d9ab508099a2eca856", size = 4332075 }, - { url = "https://files.pythonhosted.org/packages/5e/dd/425b95d0151e1d6c951f45051112394f130df3da67363b6bc75dc4c27aba/pillow-10.4.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e4d49b85c4348ea0b31ea63bc75a9f3857869174e2bf17e7aba02945cd218e6f", size = 4444808 }, - { url = "https://files.pythonhosted.org/packages/b1/84/9a15cc5726cbbfe7f9f90bfb11f5d028586595907cd093815ca6644932e3/pillow-10.4.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:6c762a5b0997f5659a5ef2266abc1d8851ad7749ad9a6a5506eb23d314e4f46b", size = 4356290 }, - { url = "https://files.pythonhosted.org/packages/b5/5b/6651c288b08df3b8c1e2f8c1152201e0b25d240e22ddade0f1e242fc9fa0/pillow-10.4.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:a985e028fc183bf12a77a8bbf36318db4238a3ded7fa9df1b9a133f1cb79f8fc", size = 4525163 }, - { url = "https://files.pythonhosted.org/packages/07/8b/34854bf11a83c248505c8cb0fcf8d3d0b459a2246c8809b967963b6b12ae/pillow-10.4.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:812f7342b0eee081eaec84d91423d1b4650bb9828eb53d8511bcef8ce5aecf1e", size = 4463100 }, - { url = "https://files.pythonhosted.org/packages/78/63/0632aee4e82476d9cbe5200c0cdf9ba41ee04ed77887432845264d81116d/pillow-10.4.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ac1452d2fbe4978c2eec89fb5a23b8387aba707ac72810d9490118817d9c0b46", size = 4592880 }, - { url = "https://files.pythonhosted.org/packages/df/56/b8663d7520671b4398b9d97e1ed9f583d4afcbefbda3c6188325e8c297bd/pillow-10.4.0-cp310-cp310-win32.whl", hash = "sha256:bcd5e41a859bf2e84fdc42f4edb7d9aba0a13d29a2abadccafad99de3feff984", size = 2235218 }, - { url = "https://files.pythonhosted.org/packages/f4/72/0203e94a91ddb4a9d5238434ae6c1ca10e610e8487036132ea9bf806ca2a/pillow-10.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:ecd85a8d3e79cd7158dec1c9e5808e821feea088e2f69a974db5edf84dc53141", size = 2554487 }, - { url = "https://files.pythonhosted.org/packages/bd/52/7e7e93d7a6e4290543f17dc6f7d3af4bd0b3dd9926e2e8a35ac2282bc5f4/pillow-10.4.0-cp310-cp310-win_arm64.whl", hash = "sha256:ff337c552345e95702c5fde3158acb0625111017d0e5f24bf3acdb9cc16b90d1", size = 2243219 }, - { url = "https://files.pythonhosted.org/packages/a7/62/c9449f9c3043c37f73e7487ec4ef0c03eb9c9afc91a92b977a67b3c0bbc5/pillow-10.4.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:0a9ec697746f268507404647e531e92889890a087e03681a3606d9b920fbee3c", size = 3509265 }, - { url = "https://files.pythonhosted.org/packages/f4/5f/491dafc7bbf5a3cc1845dc0430872e8096eb9e2b6f8161509d124594ec2d/pillow-10.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dfe91cb65544a1321e631e696759491ae04a2ea11d36715eca01ce07284738be", size = 3375655 }, - { url = "https://files.pythonhosted.org/packages/73/d5/c4011a76f4207a3c151134cd22a1415741e42fa5ddecec7c0182887deb3d/pillow-10.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5dc6761a6efc781e6a1544206f22c80c3af4c8cf461206d46a1e6006e4429ff3", size = 4340304 }, - { url = "https://files.pythonhosted.org/packages/ac/10/c67e20445a707f7a610699bba4fe050583b688d8cd2d202572b257f46600/pillow-10.4.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5e84b6cc6a4a3d76c153a6b19270b3526a5a8ed6b09501d3af891daa2a9de7d6", size = 4452804 }, - { url = "https://files.pythonhosted.org/packages/a9/83/6523837906d1da2b269dee787e31df3b0acb12e3d08f024965a3e7f64665/pillow-10.4.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:bbc527b519bd3aa9d7f429d152fea69f9ad37c95f0b02aebddff592688998abe", size = 4365126 }, - { url = "https://files.pythonhosted.org/packages/ba/e5/8c68ff608a4203085158cff5cc2a3c534ec384536d9438c405ed6370d080/pillow-10.4.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:76a911dfe51a36041f2e756b00f96ed84677cdeb75d25c767f296c1c1eda1319", size = 4533541 }, - { url = "https://files.pythonhosted.org/packages/f4/7c/01b8dbdca5bc6785573f4cee96e2358b0918b7b2c7b60d8b6f3abf87a070/pillow-10.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:59291fb29317122398786c2d44427bbd1a6d7ff54017075b22be9d21aa59bd8d", size = 4471616 }, - { url = "https://files.pythonhosted.org/packages/c8/57/2899b82394a35a0fbfd352e290945440e3b3785655a03365c0ca8279f351/pillow-10.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:416d3a5d0e8cfe4f27f574362435bc9bae57f679a7158e0096ad2beb427b8696", size = 4600802 }, - { url = "https://files.pythonhosted.org/packages/4d/d7/a44f193d4c26e58ee5d2d9db3d4854b2cfb5b5e08d360a5e03fe987c0086/pillow-10.4.0-cp311-cp311-win32.whl", hash = "sha256:7086cc1d5eebb91ad24ded9f58bec6c688e9f0ed7eb3dbbf1e4800280a896496", size = 2235213 }, - { url = "https://files.pythonhosted.org/packages/c1/d0/5866318eec2b801cdb8c82abf190c8343d8a1cd8bf5a0c17444a6f268291/pillow-10.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:cbed61494057c0f83b83eb3a310f0bf774b09513307c434d4366ed64f4128a91", size = 2554498 }, - { url = "https://files.pythonhosted.org/packages/d4/c8/310ac16ac2b97e902d9eb438688de0d961660a87703ad1561fd3dfbd2aa0/pillow-10.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:f5f0c3e969c8f12dd2bb7e0b15d5c468b51e5017e01e2e867335c81903046a22", size = 2243219 }, - { url = "https://files.pythonhosted.org/packages/05/cb/0353013dc30c02a8be34eb91d25e4e4cf594b59e5a55ea1128fde1e5f8ea/pillow-10.4.0-cp312-cp312-macosx_10_10_x86_64.whl", hash = "sha256:673655af3eadf4df6b5457033f086e90299fdd7a47983a13827acf7459c15d94", size = 3509350 }, - { url = "https://files.pythonhosted.org/packages/e7/cf/5c558a0f247e0bf9cec92bff9b46ae6474dd736f6d906315e60e4075f737/pillow-10.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:866b6942a92f56300012f5fbac71f2d610312ee65e22f1aa2609e491284e5597", size = 3374980 }, - { url = "https://files.pythonhosted.org/packages/84/48/6e394b86369a4eb68b8a1382c78dc092245af517385c086c5094e3b34428/pillow-10.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29dbdc4207642ea6aad70fbde1a9338753d33fb23ed6956e706936706f52dd80", size = 4343799 }, - { url = "https://files.pythonhosted.org/packages/3b/f3/a8c6c11fa84b59b9df0cd5694492da8c039a24cd159f0f6918690105c3be/pillow-10.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bf2342ac639c4cf38799a44950bbc2dfcb685f052b9e262f446482afaf4bffca", size = 4459973 }, - { url = "https://files.pythonhosted.org/packages/7d/1b/c14b4197b80150fb64453585247e6fb2e1d93761fa0fa9cf63b102fde822/pillow-10.4.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:f5b92f4d70791b4a67157321c4e8225d60b119c5cc9aee8ecf153aace4aad4ef", size = 4370054 }, - { url = "https://files.pythonhosted.org/packages/55/77/40daddf677897a923d5d33329acd52a2144d54a9644f2a5422c028c6bf2d/pillow-10.4.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:86dcb5a1eb778d8b25659d5e4341269e8590ad6b4e8b44d9f4b07f8d136c414a", size = 4539484 }, - { url = "https://files.pythonhosted.org/packages/40/54/90de3e4256b1207300fb2b1d7168dd912a2fb4b2401e439ba23c2b2cabde/pillow-10.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:780c072c2e11c9b2c7ca37f9a2ee8ba66f44367ac3e5c7832afcfe5104fd6d1b", size = 4477375 }, - { url = "https://files.pythonhosted.org/packages/13/24/1bfba52f44193860918ff7c93d03d95e3f8748ca1de3ceaf11157a14cf16/pillow-10.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:37fb69d905be665f68f28a8bba3c6d3223c8efe1edf14cc4cfa06c241f8c81d9", size = 4608773 }, - { url = "https://files.pythonhosted.org/packages/55/04/5e6de6e6120451ec0c24516c41dbaf80cce1b6451f96561235ef2429da2e/pillow-10.4.0-cp312-cp312-win32.whl", hash = "sha256:7dfecdbad5c301d7b5bde160150b4db4c659cee2b69589705b6f8a0c509d9f42", size = 2235690 }, - { url = "https://files.pythonhosted.org/packages/74/0a/d4ce3c44bca8635bd29a2eab5aa181b654a734a29b263ca8efe013beea98/pillow-10.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:1d846aea995ad352d4bdcc847535bd56e0fd88d36829d2c90be880ef1ee4668a", size = 2554951 }, - { url = "https://files.pythonhosted.org/packages/b5/ca/184349ee40f2e92439be9b3502ae6cfc43ac4b50bc4fc6b3de7957563894/pillow-10.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:e553cad5179a66ba15bb18b353a19020e73a7921296a7979c4a2b7f6a5cd57f9", size = 2243427 }, - { url = "https://files.pythonhosted.org/packages/c3/00/706cebe7c2c12a6318aabe5d354836f54adff7156fd9e1bd6c89f4ba0e98/pillow-10.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8bc1a764ed8c957a2e9cacf97c8b2b053b70307cf2996aafd70e91a082e70df3", size = 3525685 }, - { url = "https://files.pythonhosted.org/packages/cf/76/f658cbfa49405e5ecbfb9ba42d07074ad9792031267e782d409fd8fe7c69/pillow-10.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6209bb41dc692ddfee4942517c19ee81b86c864b626dbfca272ec0f7cff5d9fb", size = 3374883 }, - { url = "https://files.pythonhosted.org/packages/46/2b/99c28c4379a85e65378211971c0b430d9c7234b1ec4d59b2668f6299e011/pillow-10.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bee197b30783295d2eb680b311af15a20a8b24024a19c3a26431ff83eb8d1f70", size = 4339837 }, - { url = "https://files.pythonhosted.org/packages/f1/74/b1ec314f624c0c43711fdf0d8076f82d9d802afd58f1d62c2a86878e8615/pillow-10.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1ef61f5dd14c300786318482456481463b9d6b91ebe5ef12f405afbba77ed0be", size = 4455562 }, - { url = "https://files.pythonhosted.org/packages/4a/2a/4b04157cb7b9c74372fa867096a1607e6fedad93a44deeff553ccd307868/pillow-10.4.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:297e388da6e248c98bc4a02e018966af0c5f92dfacf5a5ca22fa01cb3179bca0", size = 4366761 }, - { url = "https://files.pythonhosted.org/packages/ac/7b/8f1d815c1a6a268fe90481232c98dd0e5fa8c75e341a75f060037bd5ceae/pillow-10.4.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:e4db64794ccdf6cb83a59d73405f63adbe2a1887012e308828596100a0b2f6cc", size = 4536767 }, - { url = "https://files.pythonhosted.org/packages/e5/77/05fa64d1f45d12c22c314e7b97398ffb28ef2813a485465017b7978b3ce7/pillow-10.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bd2880a07482090a3bcb01f4265f1936a903d70bc740bfcb1fd4e8a2ffe5cf5a", size = 4477989 }, - { url = "https://files.pythonhosted.org/packages/12/63/b0397cfc2caae05c3fb2f4ed1b4fc4fc878f0243510a7a6034ca59726494/pillow-10.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4b35b21b819ac1dbd1233317adeecd63495f6babf21b7b2512d244ff6c6ce309", size = 4610255 }, - { url = "https://files.pythonhosted.org/packages/7b/f9/cfaa5082ca9bc4a6de66ffe1c12c2d90bf09c309a5f52b27759a596900e7/pillow-10.4.0-cp313-cp313-win32.whl", hash = "sha256:551d3fd6e9dc15e4c1eb6fc4ba2b39c0c7933fa113b220057a34f4bb3268a060", size = 2235603 }, - { url = "https://files.pythonhosted.org/packages/01/6a/30ff0eef6e0c0e71e55ded56a38d4859bf9d3634a94a88743897b5f96936/pillow-10.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:030abdbe43ee02e0de642aee345efa443740aa4d828bfe8e2eb11922ea6a21ea", size = 2554972 }, - { url = "https://files.pythonhosted.org/packages/48/2c/2e0a52890f269435eee38b21c8218e102c621fe8d8df8b9dd06fabf879ba/pillow-10.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:5b001114dd152cfd6b23befeb28d7aee43553e2402c9f159807bf55f33af8a8d", size = 2243375 }, - { url = "https://files.pythonhosted.org/packages/38/30/095d4f55f3a053392f75e2eae45eba3228452783bab3d9a920b951ac495c/pillow-10.4.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:5b4815f2e65b30f5fbae9dfffa8636d992d49705723fe86a3661806e069352d4", size = 3493889 }, - { url = "https://files.pythonhosted.org/packages/f3/e8/4ff79788803a5fcd5dc35efdc9386af153569853767bff74540725b45863/pillow-10.4.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:8f0aef4ef59694b12cadee839e2ba6afeab89c0f39a3adc02ed51d109117b8da", size = 3346160 }, - { url = "https://files.pythonhosted.org/packages/d7/ac/4184edd511b14f760c73f5bb8a5d6fd85c591c8aff7c2229677a355c4179/pillow-10.4.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9f4727572e2918acaa9077c919cbbeb73bd2b3ebcfe033b72f858fc9fbef0026", size = 3435020 }, - { url = "https://files.pythonhosted.org/packages/da/21/1749cd09160149c0a246a81d646e05f35041619ce76f6493d6a96e8d1103/pillow-10.4.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ff25afb18123cea58a591ea0244b92eb1e61a1fd497bf6d6384f09bc3262ec3e", size = 3490539 }, - { url = "https://files.pythonhosted.org/packages/b6/f5/f71fe1888b96083b3f6dfa0709101f61fc9e972c0c8d04e9d93ccef2a045/pillow-10.4.0-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:dc3e2db6ba09ffd7d02ae9141cfa0ae23393ee7687248d46a7507b75d610f4f5", size = 3476125 }, - { url = "https://files.pythonhosted.org/packages/96/b9/c0362c54290a31866c3526848583a2f45a535aa9d725fd31e25d318c805f/pillow-10.4.0-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:02a2be69f9c9b8c1e97cf2713e789d4e398c751ecfd9967c18d0ce304efbf885", size = 3579373 }, - { url = "https://files.pythonhosted.org/packages/52/3b/ce7a01026a7cf46e5452afa86f97a5e88ca97f562cafa76570178ab56d8d/pillow-10.4.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:0755ffd4a0c6f267cccbae2e9903d95477ca2f77c4fcf3a3a09570001856c8a5", size = 2554661 }, + { url = "https://files.pythonhosted.org/packages/f0/eb/f7e21b113dd48a9c97d364e0915b3988c6a0b6207652f5a92372871b7aa4/pillow-11.0.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:1c1d72714f429a521d8d2d018badc42414c3077eb187a59579f28e4270b4b0fc", size = 3154705 }, + { url = "https://files.pythonhosted.org/packages/25/b3/2b54a1d541accebe6bd8b1358b34ceb2c509f51cb7dcda8687362490da5b/pillow-11.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:499c3a1b0d6fc8213519e193796eb1a86a1be4b1877d678b30f83fd979811d1a", size = 2979222 }, + { url = "https://files.pythonhosted.org/packages/20/12/1a41eddad8265c5c19dda8fb6c269ce15ee25e0b9f8f26286e6202df6693/pillow-11.0.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c8b2351c85d855293a299038e1f89db92a2f35e8d2f783489c6f0b2b5f3fe8a3", size = 4190220 }, + { url = "https://files.pythonhosted.org/packages/a9/9b/8a8c4d07d77447b7457164b861d18f5a31ae6418ef5c07f6f878fa09039a/pillow-11.0.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f4dba50cfa56f910241eb7f883c20f1e7b1d8f7d91c750cd0b318bad443f4d5", size = 4291399 }, + { url = "https://files.pythonhosted.org/packages/fc/e4/130c5fab4a54d3991129800dd2801feeb4b118d7630148cd67f0e6269d4c/pillow-11.0.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:5ddbfd761ee00c12ee1be86c9c0683ecf5bb14c9772ddbd782085779a63dd55b", size = 4202709 }, + { url = "https://files.pythonhosted.org/packages/39/63/b3fc299528d7df1f678b0666002b37affe6b8751225c3d9c12cf530e73ed/pillow-11.0.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:45c566eb10b8967d71bf1ab8e4a525e5a93519e29ea071459ce517f6b903d7fa", size = 4372556 }, + { url = "https://files.pythonhosted.org/packages/c6/a6/694122c55b855b586c26c694937d36bb8d3b09c735ff41b2f315c6e66a10/pillow-11.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b4fd7bd29610a83a8c9b564d457cf5bd92b4e11e79a4ee4716a63c959699b306", size = 4287187 }, + { url = "https://files.pythonhosted.org/packages/ba/a9/f9d763e2671a8acd53d29b1e284ca298bc10a595527f6be30233cdb9659d/pillow-11.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:cb929ca942d0ec4fac404cbf520ee6cac37bf35be479b970c4ffadf2b6a1cad9", size = 4418468 }, + { url = "https://files.pythonhosted.org/packages/6e/0e/b5cbad2621377f11313a94aeb44ca55a9639adabcaaa073597a1925f8c26/pillow-11.0.0-cp311-cp311-win32.whl", hash = "sha256:006bcdd307cc47ba43e924099a038cbf9591062e6c50e570819743f5607404f5", size = 2249249 }, + { url = "https://files.pythonhosted.org/packages/dc/83/1470c220a4ff06cd75fc609068f6605e567ea51df70557555c2ab6516b2c/pillow-11.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:52a2d8323a465f84faaba5236567d212c3668f2ab53e1c74c15583cf507a0291", size = 2566769 }, + { url = "https://files.pythonhosted.org/packages/52/98/def78c3a23acee2bcdb2e52005fb2810ed54305602ec1bfcfab2bda6f49f/pillow-11.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:16095692a253047fe3ec028e951fa4221a1f3ed3d80c397e83541a3037ff67c9", size = 2254611 }, + { url = "https://files.pythonhosted.org/packages/1c/a3/26e606ff0b2daaf120543e537311fa3ae2eb6bf061490e4fea51771540be/pillow-11.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d2c0a187a92a1cb5ef2c8ed5412dd8d4334272617f532d4ad4de31e0495bd923", size = 3147642 }, + { url = "https://files.pythonhosted.org/packages/4f/d5/1caabedd8863526a6cfa44ee7a833bd97f945dc1d56824d6d76e11731939/pillow-11.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:084a07ef0821cfe4858fe86652fffac8e187b6ae677e9906e192aafcc1b69903", size = 2978999 }, + { url = "https://files.pythonhosted.org/packages/d9/ff/5a45000826a1aa1ac6874b3ec5a856474821a1b59d838c4f6ce2ee518fe9/pillow-11.0.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8069c5179902dcdce0be9bfc8235347fdbac249d23bd90514b7a47a72d9fecf4", size = 4196794 }, + { url = "https://files.pythonhosted.org/packages/9d/21/84c9f287d17180f26263b5f5c8fb201de0f88b1afddf8a2597a5c9fe787f/pillow-11.0.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f02541ef64077f22bf4924f225c0fd1248c168f86e4b7abdedd87d6ebaceab0f", size = 4300762 }, + { url = "https://files.pythonhosted.org/packages/84/39/63fb87cd07cc541438b448b1fed467c4d687ad18aa786a7f8e67b255d1aa/pillow-11.0.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:fcb4621042ac4b7865c179bb972ed0da0218a076dc1820ffc48b1d74c1e37fe9", size = 4210468 }, + { url = "https://files.pythonhosted.org/packages/7f/42/6e0f2c2d5c60f499aa29be14f860dd4539de322cd8fb84ee01553493fb4d/pillow-11.0.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:00177a63030d612148e659b55ba99527803288cea7c75fb05766ab7981a8c1b7", size = 4381824 }, + { url = "https://files.pythonhosted.org/packages/31/69/1ef0fb9d2f8d2d114db982b78ca4eeb9db9a29f7477821e160b8c1253f67/pillow-11.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8853a3bf12afddfdf15f57c4b02d7ded92c7a75a5d7331d19f4f9572a89c17e6", size = 4296436 }, + { url = "https://files.pythonhosted.org/packages/44/ea/dad2818c675c44f6012289a7c4f46068c548768bc6c7f4e8c4ae5bbbc811/pillow-11.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3107c66e43bda25359d5ef446f59c497de2b5ed4c7fdba0894f8d6cf3822dafc", size = 4429714 }, + { url = "https://files.pythonhosted.org/packages/af/3a/da80224a6eb15bba7a0dcb2346e2b686bb9bf98378c0b4353cd88e62b171/pillow-11.0.0-cp312-cp312-win32.whl", hash = "sha256:86510e3f5eca0ab87429dd77fafc04693195eec7fd6a137c389c3eeb4cfb77c6", size = 2249631 }, + { url = "https://files.pythonhosted.org/packages/57/97/73f756c338c1d86bb802ee88c3cab015ad7ce4b838f8a24f16b676b1ac7c/pillow-11.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:8ec4a89295cd6cd4d1058a5e6aec6bf51e0eaaf9714774e1bfac7cfc9051db47", size = 2567533 }, + { url = "https://files.pythonhosted.org/packages/0b/30/2b61876e2722374558b871dfbfcbe4e406626d63f4f6ed92e9c8e24cac37/pillow-11.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:27a7860107500d813fcd203b4ea19b04babe79448268403172782754870dac25", size = 2254890 }, + { url = "https://files.pythonhosted.org/packages/63/24/e2e15e392d00fcf4215907465d8ec2a2f23bcec1481a8ebe4ae760459995/pillow-11.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:bcd1fb5bb7b07f64c15618c89efcc2cfa3e95f0e3bcdbaf4642509de1942a699", size = 3147300 }, + { url = "https://files.pythonhosted.org/packages/43/72/92ad4afaa2afc233dc44184adff289c2e77e8cd916b3ddb72ac69495bda3/pillow-11.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0e038b0745997c7dcaae350d35859c9715c71e92ffb7e0f4a8e8a16732150f38", size = 2978742 }, + { url = "https://files.pythonhosted.org/packages/9e/da/c8d69c5bc85d72a8523fe862f05ababdc52c0a755cfe3d362656bb86552b/pillow-11.0.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ae08bd8ffc41aebf578c2af2f9d8749d91f448b3bfd41d7d9ff573d74f2a6b2", size = 4194349 }, + { url = "https://files.pythonhosted.org/packages/cd/e8/686d0caeed6b998351d57796496a70185376ed9c8ec7d99e1d19ad591fc6/pillow-11.0.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d69bfd8ec3219ae71bcde1f942b728903cad25fafe3100ba2258b973bd2bc1b2", size = 4298714 }, + { url = "https://files.pythonhosted.org/packages/ec/da/430015cec620d622f06854be67fd2f6721f52fc17fca8ac34b32e2d60739/pillow-11.0.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:61b887f9ddba63ddf62fd02a3ba7add935d053b6dd7d58998c630e6dbade8527", size = 4208514 }, + { url = "https://files.pythonhosted.org/packages/44/ae/7e4f6662a9b1cb5f92b9cc9cab8321c381ffbee309210940e57432a4063a/pillow-11.0.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:c6a660307ca9d4867caa8d9ca2c2658ab685de83792d1876274991adec7b93fa", size = 4380055 }, + { url = "https://files.pythonhosted.org/packages/74/d5/1a807779ac8a0eeed57f2b92a3c32ea1b696e6140c15bd42eaf908a261cd/pillow-11.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:73e3a0200cdda995c7e43dd47436c1548f87a30bb27fb871f352a22ab8dcf45f", size = 4296751 }, + { url = "https://files.pythonhosted.org/packages/38/8c/5fa3385163ee7080bc13026d59656267daaaaf3c728c233d530e2c2757c8/pillow-11.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fba162b8872d30fea8c52b258a542c5dfd7b235fb5cb352240c8d63b414013eb", size = 4430378 }, + { url = "https://files.pythonhosted.org/packages/ca/1d/ad9c14811133977ff87035bf426875b93097fb50af747793f013979facdb/pillow-11.0.0-cp313-cp313-win32.whl", hash = "sha256:f1b82c27e89fffc6da125d5eb0ca6e68017faf5efc078128cfaa42cf5cb38798", size = 2249588 }, + { url = "https://files.pythonhosted.org/packages/fb/01/3755ba287dac715e6afdb333cb1f6d69740a7475220b4637b5ce3d78cec2/pillow-11.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:8ba470552b48e5835f1d23ecb936bb7f71d206f9dfeee64245f30c3270b994de", size = 2567509 }, + { url = "https://files.pythonhosted.org/packages/c0/98/2c7d727079b6be1aba82d195767d35fcc2d32204c7a5820f822df5330152/pillow-11.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:846e193e103b41e984ac921b335df59195356ce3f71dcfd155aa79c603873b84", size = 2254791 }, + { url = "https://files.pythonhosted.org/packages/eb/38/998b04cc6f474e78b563716b20eecf42a2fa16a84589d23c8898e64b0ffd/pillow-11.0.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:4ad70c4214f67d7466bea6a08061eba35c01b1b89eaa098040a35272a8efb22b", size = 3150854 }, + { url = "https://files.pythonhosted.org/packages/13/8e/be23a96292113c6cb26b2aa3c8b3681ec62b44ed5c2bd0b258bd59503d3c/pillow-11.0.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:6ec0d5af64f2e3d64a165f490d96368bb5dea8b8f9ad04487f9ab60dc4bb6003", size = 2982369 }, + { url = "https://files.pythonhosted.org/packages/97/8a/3db4eaabb7a2ae8203cd3a332a005e4aba00067fc514aaaf3e9721be31f1/pillow-11.0.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c809a70e43c7977c4a42aefd62f0131823ebf7dd73556fa5d5950f5b354087e2", size = 4333703 }, + { url = "https://files.pythonhosted.org/packages/28/ac/629ffc84ff67b9228fe87a97272ab125bbd4dc462745f35f192d37b822f1/pillow-11.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:4b60c9520f7207aaf2e1d94de026682fc227806c6e1f55bba7606d1c94dd623a", size = 4412550 }, + { url = "https://files.pythonhosted.org/packages/d6/07/a505921d36bb2df6868806eaf56ef58699c16c388e378b0dcdb6e5b2fb36/pillow-11.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:1e2688958a840c822279fda0086fec1fdab2f95bf2b717b66871c4ad9859d7e8", size = 4461038 }, + { url = "https://files.pythonhosted.org/packages/d6/b9/fb620dd47fc7cc9678af8f8bd8c772034ca4977237049287e99dda360b66/pillow-11.0.0-cp313-cp313t-win32.whl", hash = "sha256:607bbe123c74e272e381a8d1957083a9463401f7bd01287f50521ecb05a313f8", size = 2253197 }, + { url = "https://files.pythonhosted.org/packages/df/86/25dde85c06c89d7fc5db17940f07aae0a56ac69aa9ccb5eb0f09798862a8/pillow-11.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5c39ed17edea3bc69c743a8dd3e9853b7509625c2462532e62baa0732163a904", size = 2572169 }, + { url = "https://files.pythonhosted.org/packages/51/85/9c33f2517add612e17f3381aee7c4072779130c634921a756c97bc29fb49/pillow-11.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:75acbbeb05b86bc53cbe7b7e6fe00fbcf82ad7c684b3ad82e3d711da9ba287d3", size = 2256828 }, ] [[package]] @@ -740,10 +649,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/43/1d/81d59d228381576b92ecede5cd7239762c14001a828bdba30d64896e9778/pycryptodome-3.21.0-cp36-abi3-win_amd64.whl", hash = "sha256:b7aa25fc0baa5b1d95b7633af4f5f1838467f1815442b22487426f94e0d66c53", size = 1812863 }, { url = "https://files.pythonhosted.org/packages/25/b3/09ff7072e6d96c9939c24cf51d3c389d7c345bf675420355c22402f71b68/pycryptodome-3.21.0-pp27-pypy_73-manylinux2010_x86_64.whl", hash = "sha256:2cb635b67011bc147c257e61ce864879ffe6d03342dc74b6045059dfbdedafca", size = 1691593 }, { url = "https://files.pythonhosted.org/packages/a8/91/38e43628148f68ba9b68dedbc323cf409e537fd11264031961fd7c744034/pycryptodome-3.21.0-pp27-pypy_73-win32.whl", hash = "sha256:4c26a2f0dc15f81ea3afa3b0c87b87e501f235d332b7f27e2225ecb80c0b1cdd", size = 1765997 }, - { url = "https://files.pythonhosted.org/packages/08/16/ae464d4ac338c1dd41f89c41f9488e54f7d2a3acf93bb920bb193b99f8e3/pycryptodome-3.21.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:d5ebe0763c982f069d3877832254f64974139f4f9655058452603ff559c482e8", size = 1615855 }, - { url = "https://files.pythonhosted.org/packages/1e/8c/b0cee957eee1950ce7655006b26a8894cee1dc4b8747ae913684352786eb/pycryptodome-3.21.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7ee86cbde706be13f2dec5a42b52b1c1d1cbb90c8e405c68d0755134735c8dc6", size = 1650018 }, - { url = "https://files.pythonhosted.org/packages/93/4d/d7138068089b99f6b0368622e60f97a577c936d75f533552a82613060c58/pycryptodome-3.21.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0fd54003ec3ce4e0f16c484a10bc5d8b9bd77fa662a12b85779a2d2d85d67ee0", size = 1687977 }, - { url = "https://files.pythonhosted.org/packages/96/02/90ae1ac9f28be4df0ed88c127bf4acc1b102b40053e172759d4d1c54d937/pycryptodome-3.21.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:5dfafca172933506773482b0e18f0cd766fd3920bd03ec85a283df90d8a17bc6", size = 1788273 }, ] [[package]] @@ -769,18 +674,6 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/e2/aa/6b6a9b9f8537b872f552ddd46dd3da230367754b6f707b8e1e963f515ea3/pydantic_core-2.23.4.tar.gz", hash = "sha256:2584f7cf844ac4d970fba483a717dbe10c1c1c96a969bf65d61ffe94df1b2863", size = 402156 } wheels = [ - { url = "https://files.pythonhosted.org/packages/5c/8b/d3ae387f66277bd8104096d6ec0a145f4baa2966ebb2cad746c0920c9526/pydantic_core-2.23.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:b10bd51f823d891193d4717448fab065733958bdb6a6b351967bd349d48d5c9b", size = 1867835 }, - { url = "https://files.pythonhosted.org/packages/46/76/f68272e4c3a7df8777798282c5e47d508274917f29992d84e1898f8908c7/pydantic_core-2.23.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4fc714bdbfb534f94034efaa6eadd74e5b93c8fa6315565a222f7b6f42ca1166", size = 1776689 }, - { url = "https://files.pythonhosted.org/packages/cc/69/5f945b4416f42ea3f3bc9d2aaec66c76084a6ff4ff27555bf9415ab43189/pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:63e46b3169866bd62849936de036f901a9356e36376079b05efa83caeaa02ceb", size = 1800748 }, - { url = "https://files.pythonhosted.org/packages/50/ab/891a7b0054bcc297fb02d44d05c50e68154e31788f2d9d41d0b72c89fdf7/pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed1a53de42fbe34853ba90513cea21673481cd81ed1be739f7f2efb931b24916", size = 1806469 }, - { url = "https://files.pythonhosted.org/packages/31/7c/6e3fa122075d78f277a8431c4c608f061881b76c2b7faca01d317ee39b5d/pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cfdd16ab5e59fc31b5e906d1a3f666571abc367598e3e02c83403acabc092e07", size = 2002246 }, - { url = "https://files.pythonhosted.org/packages/ad/6f/22d5692b7ab63fc4acbc74de6ff61d185804a83160adba5e6cc6068e1128/pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:255a8ef062cbf6674450e668482456abac99a5583bbafb73f9ad469540a3a232", size = 2659404 }, - { url = "https://files.pythonhosted.org/packages/11/ac/1e647dc1121c028b691028fa61a4e7477e6aeb5132628fde41dd34c1671f/pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4a7cd62e831afe623fbb7aabbb4fe583212115b3ef38a9f6b71869ba644624a2", size = 2053940 }, - { url = "https://files.pythonhosted.org/packages/91/75/984740c17f12c3ce18b5a2fcc4bdceb785cce7df1511a4ce89bca17c7e2d/pydantic_core-2.23.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f09e2ff1f17c2b51f2bc76d1cc33da96298f0a036a137f5440ab3ec5360b624f", size = 1921437 }, - { url = "https://files.pythonhosted.org/packages/a0/74/13c5f606b64d93f0721e7768cd3e8b2102164866c207b8cd6f90bb15d24f/pydantic_core-2.23.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e38e63e6f3d1cec5a27e0afe90a085af8b6806ee208b33030e65b6516353f1a3", size = 1966129 }, - { url = "https://files.pythonhosted.org/packages/18/03/9c4aa5919457c7b57a016c1ab513b1a926ed9b2bb7915bf8e506bf65c34b/pydantic_core-2.23.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:0dbd8dbed2085ed23b5c04afa29d8fd2771674223135dc9bc937f3c09284d071", size = 2110908 }, - { url = "https://files.pythonhosted.org/packages/92/2c/053d33f029c5dc65e5cf44ff03ceeefb7cce908f8f3cca9265e7f9b540c8/pydantic_core-2.23.4-cp310-none-win32.whl", hash = "sha256:6531b7ca5f951d663c339002e91aaebda765ec7d61b7d1e3991051906ddde119", size = 1735278 }, - { url = "https://files.pythonhosted.org/packages/de/81/7dfe464eca78d76d31dd661b04b5f2036ec72ea8848dd87ab7375e185c23/pydantic_core-2.23.4-cp310-none-win_amd64.whl", hash = "sha256:7c9129eb40958b3d4500fa2467e6a83356b3b61bfff1b414c7361d9220f9ae8f", size = 1917453 }, { url = "https://files.pythonhosted.org/packages/5d/30/890a583cd3f2be27ecf32b479d5d615710bb926d92da03e3f7838ff3e58b/pydantic_core-2.23.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:77733e3892bb0a7fa797826361ce8a9184d25c8dffaec60b7ffe928153680ba8", size = 1865160 }, { url = "https://files.pythonhosted.org/packages/1d/9a/b634442e1253bc6889c87afe8bb59447f106ee042140bd57680b3b113ec7/pydantic_core-2.23.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1b84d168f6c48fabd1f2027a3d1bdfe62f92cade1fb273a5d68e621da0e44e6d", size = 1776777 }, { url = "https://files.pythonhosted.org/packages/75/9a/7816295124a6b08c24c96f9ce73085032d8bcbaf7e5a781cd41aa910c891/pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:df49e7a0861a8c36d089c1ed57d308623d60416dab2647a4a17fe050ba85de0e", size = 1799244 }, @@ -817,14 +710,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/16/16/b805c74b35607d24d37103007f899abc4880923b04929547ae68d478b7f4/pydantic_core-2.23.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ed541d70698978a20eb63d8c5d72f2cc6d7079d9d90f6b50bad07826f1320f5f", size = 2116814 }, { url = "https://files.pythonhosted.org/packages/d1/58/5305e723d9fcdf1c5a655e6a4cc2a07128bf644ff4b1d98daf7a9dbf57da/pydantic_core-2.23.4-cp313-none-win32.whl", hash = "sha256:3d5639516376dce1940ea36edf408c554475369f5da2abd45d44621cb616f769", size = 1738360 }, { url = "https://files.pythonhosted.org/packages/a5/ae/e14b0ff8b3f48e02394d8acd911376b7b66e164535687ef7dc24ea03072f/pydantic_core-2.23.4-cp313-none-win_amd64.whl", hash = "sha256:5a1504ad17ba4210df3a045132a7baeeba5a200e930f57512ee02909fc5c4cb5", size = 1919411 }, - { url = "https://files.pythonhosted.org/packages/13/a9/5d582eb3204464284611f636b55c0a7410d748ff338756323cb1ce721b96/pydantic_core-2.23.4-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:f455ee30a9d61d3e1a15abd5068827773d6e4dc513e795f380cdd59932c782d5", size = 1857135 }, - { url = "https://files.pythonhosted.org/packages/2c/57/faf36290933fe16717f97829eabfb1868182ac495f99cf0eda9f59687c9d/pydantic_core-2.23.4-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:1e90d2e3bd2c3863d48525d297cd143fe541be8bbf6f579504b9712cb6b643ec", size = 1740583 }, - { url = "https://files.pythonhosted.org/packages/91/7c/d99e3513dc191c4fec363aef1bf4c8af9125d8fa53af7cb97e8babef4e40/pydantic_core-2.23.4-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2e203fdf807ac7e12ab59ca2bfcabb38c7cf0b33c41efeb00f8e5da1d86af480", size = 1793637 }, - { url = "https://files.pythonhosted.org/packages/29/18/812222b6d18c2d13eebbb0f7cdc170a408d9ced65794fdb86147c77e1982/pydantic_core-2.23.4-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e08277a400de01bc72436a0ccd02bdf596631411f592ad985dcee21445bd0068", size = 1941963 }, - { url = "https://files.pythonhosted.org/packages/0f/36/c1f3642ac3f05e6bb4aec3ffc399fa3f84895d259cf5f0ce3054b7735c29/pydantic_core-2.23.4-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f220b0eea5965dec25480b6333c788fb72ce5f9129e8759ef876a1d805d00801", size = 1915332 }, - { url = "https://files.pythonhosted.org/packages/f7/ca/9c0854829311fb446020ebb540ee22509731abad886d2859c855dd29b904/pydantic_core-2.23.4-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:d06b0c8da4f16d1d1e352134427cb194a0a6e19ad5db9161bf32b2113409e728", size = 1957926 }, - { url = "https://files.pythonhosted.org/packages/c0/1c/7836b67c42d0cd4441fcd9fafbf6a027ad4b79b6559f80cf11f89fd83648/pydantic_core-2.23.4-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:ba1a0996f6c2773bd83e63f18914c1de3c9dd26d55f4ac302a7efe93fb8e7433", size = 2100342 }, - { url = "https://files.pythonhosted.org/packages/a9/f9/b6bcaf874f410564a78908739c80861a171788ef4d4f76f5009656672dfe/pydantic_core-2.23.4-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:9a5bce9d23aac8f0cf0836ecfc033896aa8443b501c58d0602dbfd5bd5b37753", size = 1920344 }, ] [[package]] @@ -851,11 +736,9 @@ version = "8.3.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, { name = "iniconfig" }, { name = "packaging" }, { name = "pluggy" }, - { name = "tomli", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/8b/6c/62bbd536103af674e227c41a8f3dcd022d591f6eed5facb5a0f31ee33bbc/pytest-8.3.3.tar.gz", hash = "sha256:70b98107bd648308a7952b06e6ca9a50bc660be218d53c257cc1fc94fda10181", size = 1442487 } wheels = [ @@ -889,15 +772,6 @@ version = "6.0.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631 } wheels = [ - { url = "https://files.pythonhosted.org/packages/9b/95/a3fac87cb7158e231b5a6012e438c647e1a87f09f8e0d123acec8ab8bf71/PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086", size = 184199 }, - { url = "https://files.pythonhosted.org/packages/c7/7a/68bd47624dab8fd4afbfd3c48e3b79efe09098ae941de5b58abcbadff5cb/PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf", size = 171758 }, - { url = "https://files.pythonhosted.org/packages/49/ee/14c54df452143b9ee9f0f29074d7ca5516a36edb0b4cc40c3f280131656f/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237", size = 718463 }, - { url = "https://files.pythonhosted.org/packages/4d/61/de363a97476e766574650d742205be468921a7b532aa2499fcd886b62530/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b", size = 719280 }, - { url = "https://files.pythonhosted.org/packages/6b/4e/1523cb902fd98355e2e9ea5e5eb237cbc5f3ad5f3075fa65087aa0ecb669/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed", size = 751239 }, - { url = "https://files.pythonhosted.org/packages/b7/33/5504b3a9a4464893c32f118a9cc045190a91637b119a9c881da1cf6b7a72/PyYAML-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180", size = 695802 }, - { url = "https://files.pythonhosted.org/packages/5c/20/8347dcabd41ef3a3cdc4f7b7a2aff3d06598c8779faa189cdbf878b626a4/PyYAML-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68", size = 720527 }, - { url = "https://files.pythonhosted.org/packages/be/aa/5afe99233fb360d0ff37377145a949ae258aaab831bde4792b32650a4378/PyYAML-6.0.2-cp310-cp310-win32.whl", hash = "sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99", size = 144052 }, - { url = "https://files.pythonhosted.org/packages/b5/84/0fa4b06f6d6c958d207620fc60005e241ecedceee58931bb20138e1e5776/PyYAML-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e", size = 161774 }, { url = "https://files.pythonhosted.org/packages/f8/aa/7af4e81f7acba21a4c6be026da38fd2b872ca46226673c89a758ebdc4fd2/PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774", size = 184612 }, { url = "https://files.pythonhosted.org/packages/8b/62/b9faa998fd185f65c1371643678e4d58254add437edb764a08c5a98fb986/PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee", size = 172040 }, { url = "https://files.pythonhosted.org/packages/ad/0c/c804f5f922a9a6563bab712d8dcc70251e8af811fce4524d57c2c0fd49a4/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c", size = 736829 }, @@ -973,7 +847,6 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markdown-it-py" }, { name = "pygments" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ab/3a/0316b28d0761c6734d6bc14e770d85506c986c85ffb239e688eeaab2c2bc/rich-13.9.4.tar.gz", hash = "sha256:439594978a49a09530cff7ebc4b5c7103ef57baf48d5ea3184f21d9a2befa098", size = 223149 } wheels = [ @@ -1043,7 +916,7 @@ wheels = [ [[package]] name = "stream" -version = "0.5.1" +version = "0.6.0" source = { editable = "." } dependencies = [ { name = "framegrab" }, @@ -1064,12 +937,12 @@ dev = [ [package.metadata] requires-dist = [ - { name = "framegrab", specifier = ">=0.7.0" }, - { name = "groundlight", specifier = ">=0.18.4" }, + { name = "framegrab", specifier = ">=0.8.0" }, + { name = "groundlight", specifier = ">=0.20.0" }, { name = "jsonformatter", specifier = ">=0.3.2" }, { name = "numpy", specifier = "<2.0.0" }, { name = "opencv-python-headless", specifier = ">=4.10.0.84" }, - { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" }, { name = "pyyaml", specifier = "==6.0.2" }, { name = "ruff", marker = "extra == 'dev'", specifier = "==0.7.2" }, { name = "streamlink", specifier = "==7.0.0" }, @@ -1081,7 +954,6 @@ version = "7.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, - { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, { name = "isodate" }, { name = "lxml" }, { name = "pycountry" }, @@ -1101,15 +973,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a0/c3/e64adfb53dcabfed7ab4fa9e35aaae80f690c1eca6e4aa7d4ac31f56c119/streamlink-7.0.0-py3-none-win_amd64.whl", hash = "sha256:6bcf530168178a5b1b86b3b2461d64fdc33fdb2ba9cd04303b9c4a946cd93106", size = 531232 }, ] -[[package]] -name = "tomli" -version = "2.0.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/35/b9/de2a5c0144d7d75a57ff355c0c24054f965b2dc3036456ae03a51ea6264b/tomli-2.0.2.tar.gz", hash = "sha256:d46d457a85337051c36524bc5349dd91b1877838e2979ac5ced3e710ed8a60ed", size = 16096 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cf/db/ce8eda256fa131af12e0a76d481711abe4681b6923c27efb9a255c9e4594/tomli-2.0.2-py3-none-any.whl", hash = "sha256:2ebe24485c53d303f690b0ec092806a085f07af5a5aa1464f3931eec36caaa38", size = 13237 }, -] - [[package]] name = "trio" version = "0.27.0" @@ -1117,7 +980,6 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "attrs" }, { name = "cffi", marker = "implementation_name != 'pypy' and os_name == 'nt'" }, - { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, { name = "idna" }, { name = "outcome" }, { name = "sniffio" }, @@ -1133,7 +995,6 @@ name = "trio-websocket" version = "0.11.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, { name = "trio" }, { name = "wsproto" }, ] From 4ae9b48c5e5ee8212418b62233a02046dda98f2b Mon Sep 17 00:00:00 2001 From: Tyler Romero Date: Mon, 9 Dec 2024 09:03:15 -0800 Subject: [PATCH 3/6] robustness improvements --- src/stream/main.py | 51 +++++++++++++++++++++++++++++++------------ src/stream/threads.py | 37 ++++++++++++++++++++++++++----- 2 files changed, 68 insertions(+), 20 deletions(-) diff --git a/src/stream/main.py b/src/stream/main.py index 82dd074..c0d28e8 100644 --- a/src/stream/main.py +++ b/src/stream/main.py @@ -11,7 +11,7 @@ import cv2 import yaml -from framegrab import FrameGrabber, MotionDetector +from framegrab import FrameGrabber, GrabError, MotionDetector from groundlight import Groundlight from stream.grabber import StreamType, framegrabber_factory @@ -44,7 +44,7 @@ def process_single_frame(frame: cv2.Mat, gl: Groundlight, detector: str) -> None: - """Process a single frame and send it to Groundlight + """Process a single frame and send it to Groundlight. Args: frame: OpenCV image frame to process @@ -87,17 +87,17 @@ def validate_stream_args(args: argparse.Namespace) -> tuple[str | int, StreamTyp return stream, stream_type -def parse_motion_args(args: argparse.Namespace) -> tuple[bool, float, float, float]: +def parse_motion_args(args: argparse.Namespace) -> tuple[bool, float, int, float, float]: """Parse and validate motion detection arguments""" if not args.motion: logger.info("Motion detection disabled.") - return False, 0, 0, 0 + return False, 0, 0, 0, 0 logger.info( - f"Motion detection enabled with threshold={args.threshold} and post-motion capture of {args.postmotion}s " + f"Motion detection enabled with pixel_threshold={args.motion_pixel_threshold}, value_threshold={args.motion_val_threshold} post-motion capture of {args.postmotion}s " f"and max interval of {args.maxinterval}s" ) - return True, args.threshold, args.postmotion, args.maxinterval + return True, args.motion_pixel_threshold, args.motion_val_threshold, args.postmotion, args.maxinterval def run_capture_loop( # noqa: PLR0912 PLR0913 @@ -121,8 +121,13 @@ def run_capture_loop( # noqa: PLR0912 PLR0913 while True: start = time.time() - frame = grabber.grab() - if frame is None: + try: + frame = grabber.grab() + except GrabError: + logger.exception("Error grabbing frame") + frame = None + + if frame is None: # No frame captured, or exception occurred logger.warning("No frame captured!") time.sleep(0.1) # Brief pause before retrying continue @@ -224,10 +229,17 @@ def main(): ) parser.add_argument( "-r", - "--threshold", + "--motion_pixel_threshold", type=float, default=1, - help="Motion detection threshold (%% pixels changed). Defaults to 1%%.", + help="Motion detection pixel threshold (%% pixels changed). Defaults to 1%%.", + ) + parser.add_argument( + "-b", + "--motion_val_threshold", + type=int, + default=20, + help="Motion detection value threshold (degree of change). Defaults to 20.", ) parser.add_argument( "-p", @@ -272,7 +284,9 @@ def main(): crop_region = parse_crop_string(args.crop) if args.crop else None stream, stream_type = validate_stream_args(args) - motion_detect, motion_threshold, post_motion_time, max_frame_interval = parse_motion_args(args) + motion_detect, motion_pixel_threshold, motion_val_threshold, post_motion_time, max_frame_interval = ( + parse_motion_args(args) + ) # Setup Groundlight client gl = Groundlight(endpoint=args.endpoint, api_token=args.token) @@ -294,7 +308,11 @@ def main(): queue, tc, workers = setup_workers(fn=_process_single_frame, num_workers=worker_count) # Setup motion detection if enabled - motion_detector = MotionDetector(pct_threshold=motion_threshold) if motion_detect else None + motion_detector = ( + MotionDetector(pct_threshold=motion_pixel_threshold, val_threshold=motion_val_threshold) + if motion_detect + else None + ) print_banner(gl=gl, args=args) @@ -311,8 +329,13 @@ def main(): ) except KeyboardInterrupt: logger.info("Exiting with KeyboardInterrupt.") - tc.force_exit() - sys.exit(-1) + except Exception as e: + logger.error(f"Exiting with exception: {e}", exc_info=True) + finally: + # Clean up threads + tc.shutdown() + for worker in workers: + worker.join(timeout=5.0) if __name__ == "__main__": diff --git a/src/stream/threads.py b/src/stream/threads.py index dfc19f9..34b6273 100644 --- a/src/stream/threads.py +++ b/src/stream/threads.py @@ -13,25 +13,40 @@ class ThreadControl: - """Controls graceful shutdown of worker threads""" + """Gracefully shutdown all worker threads + + Args: + timeout: Maximum time to wait for threads to finish + Returns: + bool: True if all threads completed, False if timeout occurred + """ def __init__(self): self.exit_all_threads = False - def force_exit(self): + def shutdown(self) -> bool: logger.debug("Attempting force exit of all threads") self.exit_all_threads = True + return True + +def setup_workers( + fn: Callable, num_workers: int = 10, daemon: bool = True +) -> tuple[Queue, ThreadControl, list[Thread]]: + """Setup worker threads and queues -def setup_workers(fn: Callable, num_workers: int = 10) -> tuple[Queue, ThreadControl, list[Thread]]: - """Setup worker threads and queues""" + Args: + fn: Function to process work items + num_workers: Number of worker threads + daemon: If True, threads will be daemon threads that exit when main thread exits + """ q = Queue() tc = ThreadControl() workers = [] for _ in range(num_workers): - thread = Thread(target=worker_loop, kwargs=dict(q=q, control=tc, fn=fn)) + thread = Thread(target=worker_loop, kwargs=dict(q=q, control=tc, fn=fn), daemon=daemon) workers.append(thread) thread.start() @@ -49,8 +64,18 @@ def worker_loop(q: Queue, control: ThreadControl, fn: Callable): while not control.exit_all_threads: try: work = q.get(timeout=1) # Timeout prevents orphaned threads - fn(work) + + try: + fn(work) + except Exception as e: + logger.error(f"Error processing work item: {e}", exc_info=True) + finally: + q.task_done() # Signal completion even if there was an error + except Empty: continue + except Exception as e: + logger.error(f"Critical error in worker thread: {e}", exc_info=True) + break logger.debug("exiting worker thread.") From f19346766bf2c22f33629945e8b663d185e6211c Mon Sep 17 00:00:00 2001 From: Auto-format Bot Date: Thu, 12 Dec 2024 15:37:08 +0000 Subject: [PATCH 4/6] Automatically reformatting code with ruff --- src/stream/main.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/stream/main.py b/src/stream/main.py index c0d28e8..a4c4631 100644 --- a/src/stream/main.py +++ b/src/stream/main.py @@ -3,7 +3,6 @@ import logging import math import os -import sys import time from functools import partial from logging.config import dictConfig From fa69befc5d8ac8d589075ce1913b001f8a9e03a2 Mon Sep 17 00:00:00 2001 From: Tyler Romero Date: Tue, 17 Dec 2024 15:59:49 -0800 Subject: [PATCH 5/6] Refactor stream to use framegrab. --- Dockerfile | 35 +++++++++++++++++++++++++---------- pyproject.toml | 4 ++-- src/stream/grabber.py | 13 +++++-------- src/stream/main.py | 20 +++++++------------- test/test_grabber_creation.py | 12 ++++-------- uv.lock | 16 ++++++++-------- 6 files changed, 51 insertions(+), 49 deletions(-) diff --git a/Dockerfile b/Dockerfile index ffc956c..67ce705 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,12 +1,11 @@ -FROM python:3.11-slim-bookworm +# Stage 1: Build environment +FROM python:3.11-slim-bookworm AS builder -# Install dependencies -RUN apt-get update && apt-get install -y \ - gcc \ - libgl1 \ - libglib2.0-0 \ - && apt-get clean \ - && rm -rf /var/lib/apt/lists/* +# Install dependencies and clean up +RUN apt-get update && \ + apt-get install -y gcc && \ + apt-get clean && \ + rm -rf /var/lib/apt/lists/* # Use uv to install python dependencies. WORKDIR /app @@ -20,7 +19,23 @@ RUN --mount=from=ghcr.io/astral-sh/uv,source=/uv,target=/bin/uv \ # Add source code COPY . /app/ -# Install the project +# Stage 2: Final image +FROM python:3.11-slim-bookworm + +# Install OpenGL and GLib libraries +RUN apt-get update && \ + apt-get install -y libgl1 libglib2.0-0 && \ + apt-get clean && \ + rm -rf /var/lib/apt/lists/* + +# Copy only the necessary files from the builder stage +WORKDIR /app +COPY --from=builder /app/.venv /app/.venv +COPY --from=builder /app/src /app/src +COPY --from=builder /app/pyproject.toml /app/pyproject.toml +COPY --from=builder /app/uv.lock /app/uv.lock + +# Install the project in the final stage RUN --mount=from=ghcr.io/astral-sh/uv,source=/uv,target=/bin/uv \ uv sync --no-dev --frozen --no-editable --no-cache @@ -29,4 +44,4 @@ ENV VIRTUAL_ENV=/app/.venv ENV PATH="/app/.venv/bin:$PATH" # Run the application -ENTRYPOINT ["python", "-m", "stream"] +ENTRYPOINT ["python", "-m", "stream"] \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 6f94cd7..a37105e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,8 +5,8 @@ description = "Groundlight Stream Processor - Container for analyzing video usin readme = "README.md" requires-python = ">=3.11" dependencies = [ - "framegrab>=0.8.0", - "groundlight>=0.20.0", + "framegrab>=0.9.0", + "groundlight>=0.21.0", "jsonformatter>=0.3.2", "numpy<2.0.0", "opencv-python-headless>=4.10.0.84", diff --git a/src/stream/grabber.py b/src/stream/grabber.py index 432973f..984d117 100644 --- a/src/stream/grabber.py +++ b/src/stream/grabber.py @@ -13,9 +13,7 @@ class StreamType(StrEnum): RTSP = "rtsp" HLS = "hls" YOUTUBE_LIVE = "youtube_live" - DIRECTORY = "directory" # Not implemented yet - FILE = "file" # Not implemented yet - IMAGE_URL = "image_url" # Not implemented yet + FILE = "file" def _infer_stream_type(stream: str | int) -> StreamType: @@ -38,11 +36,8 @@ def _infer_stream_type(stream: str | int) -> StreamType: return StreamType.YOUTUBE_LIVE raise NotImplementedError("Image URL stream type is not supported yet") - # Check file patterns - if "*" in stream: - raise NotImplementedError("Directory stream type is not supported yet") if Path(stream).is_file(): - raise NotImplementedError("File stream type is not supported yet") + return StreamType.FILE raise ValueError(f"Could not infer stream type from: {stream}") @@ -56,6 +51,8 @@ def _stream_to_id(stream: str | int, stream_type: StreamType) -> dict[str, str | return {"hls_url": stream} elif stream_type == StreamType.GENERIC_USB: return {"serial_number": stream} + elif stream_type == StreamType.FILE: + return {"filename": stream} return None @@ -74,7 +71,7 @@ def _configure_options( options["resolution.width"] = width if max_fps is not None: - if stream_type == StreamType.RTSP: + if stream_type in [StreamType.RTSP, StreamType.FILE]: options["max_fps"] = max_fps else: logger.warning(f"max_fps is not supported for stream type {stream_type}") diff --git a/src/stream/main.py b/src/stream/main.py index a4c4631..9181de4 100644 --- a/src/stream/main.py +++ b/src/stream/main.py @@ -24,23 +24,16 @@ HELP_TEXT = """Groundlight Stream Processor -A command-line tool that captures frames from a video source and sends them to a Groundlight detector for analysis. +Captures frames from video sources and sends them to Groundlight detectors for analysis. -Supports a variety of input sources including: -- Video devices (usb cameras, webcams, etc) +Supported input sources: +- USB cameras and webcams - RTSP streams - YouTube Live streams - HLS streams -- Image directories -- Video files (mp4, etc) -- Image URLs +- Video files (mp4, mov, mjpeg) """ -# TODO list: -# - Reintroduce support for image URLs in upstream framegrab lib -# - Reintroduce support for image directories in upstream framegrab lib -# - Reintroduce support for video files in upstream framegrab lib - def process_single_frame(frame: cv2.Mat, gl: Groundlight, detector: str) -> None: """Process a single frame and send it to Groundlight. @@ -93,7 +86,8 @@ def parse_motion_args(args: argparse.Namespace) -> tuple[bool, float, int, float return False, 0, 0, 0, 0 logger.info( - f"Motion detection enabled with pixel_threshold={args.motion_pixel_threshold}, value_threshold={args.motion_val_threshold} post-motion capture of {args.postmotion}s " + f"Motion detection enabled with pixel_threshold={args.motion_pixel_threshold}, " + f"value_threshold={args.motion_val_threshold} post-motion capture of {args.postmotion}s " f"and max interval of {args.maxinterval}s" ) return True, args.motion_pixel_threshold, args.motion_val_threshold, args.postmotion, args.maxinterval @@ -183,7 +177,7 @@ def print_banner(gl: Groundlight, args: argparse.Namespace) -> None: print(f" Target Detector: {detector}") print(f" Groundlight Endpoint: {gl.endpoint}") print(f" Whoami: {gl.whoami()}") - print(f" Frames/sec: {args.fps} (Seconds/frame: {1/args.fps:.3f})") + print(f" Max Frames/sec: {args.fps} (Seconds/frame: {1/args.fps:.3f})") print(f" Motion Detection: {motdet}") print("==================================================") diff --git a/test/test_grabber_creation.py b/test/test_grabber_creation.py index 16b5ae1..aa61c37 100644 --- a/test/test_grabber_creation.py +++ b/test/test_grabber_creation.py @@ -1,7 +1,6 @@ import pytest -from framegrab import FrameGrabber -from stream.grabber import StreamType, _configure_options, _infer_stream_type, _stream_to_id, framegrabber_factory +from stream.grabber import StreamType, _configure_options, _infer_stream_type, _stream_to_id def test_infer_stream_type(): @@ -28,12 +27,6 @@ def test_infer_stream_type(): with pytest.raises(ValueError): _infer_stream_type("invalid://stream") - # Test unimplemented types - with pytest.raises(NotImplementedError): - _infer_stream_type("http://example.com/image.jpg") - with pytest.raises(NotImplementedError): - _infer_stream_type("*.jpg") - def test_stream_to_id(): # Test YouTube stream @@ -68,6 +61,9 @@ def test_configure_options(): opts = _configure_options(StreamType.RTSP, max_fps=30) assert opts["max_fps"] == 30 + opts = _configure_options(StreamType.FILE, max_fps=40) + assert opts["max_fps"] == 40 + # Test max_fps warning for unsupported types opts = _configure_options(StreamType.GENERIC_USB, max_fps=30) assert "max_fps" not in opts diff --git a/uv.lock b/uv.lock index 3e8082f..289ef86 100644 --- a/uv.lock +++ b/uv.lock @@ -211,7 +211,7 @@ wheels = [ [[package]] name = "framegrab" -version = "0.8.0" +version = "0.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "ascii-magic" }, @@ -223,9 +223,9 @@ dependencies = [ { name = "pyyaml" }, { name = "wsdiscovery" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ac/75/fecc3baadb93bdb0f5033d8bb13a91a7217da0555dc1ff2a2f1eda5746de/framegrab-0.8.0.tar.gz", hash = "sha256:e1553f4985eb72d949aaf5a906d4e3078ada40b6a17e200bd32bff01430e136d", size = 28168 } +sdist = { url = "https://files.pythonhosted.org/packages/bc/7b/745d23f45237bc96ccb580df9ec44244fabb68d5833f5fe4dba595ddc363/framegrab-0.9.0.tar.gz", hash = "sha256:b0afba8e2df710bbbf1097101c11652bae3f53a9b12a919fe396189908e20d32", size = 29972 } wheels = [ - { url = "https://files.pythonhosted.org/packages/18/7a/e7c0cc740e256b71e95b0906980b84fc1fdd4efbe66c718ab86587b55928/framegrab-0.8.0-py3-none-any.whl", hash = "sha256:725fabc8b5f98a971ffdba0c47810234442837bf6916edb708c8db6287401d01", size = 27979 }, + { url = "https://files.pythonhosted.org/packages/4c/b2/5461f35dddbd9a657327d19a3c9acc573216a47e41d47b8c08ad27b3565c/framegrab-0.9.0-py3-none-any.whl", hash = "sha256:30f38e822abe51f6f5bb35bec4b749edc41c4877cb600543ba4d551cb4e7b22d", size = 29447 }, ] [[package]] @@ -241,7 +241,7 @@ wheels = [ [[package]] name = "groundlight" -version = "0.20.0" +version = "0.21.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, @@ -253,9 +253,9 @@ dependencies = [ { name = "typer" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/95/a6/e7464fc64bd90c49c9550aca5cde2c939110eb02454f72259a437c4b6667/groundlight-0.20.0.tar.gz", hash = "sha256:2be9e0399675d0eead7b45bf2bac5067b7d9b01391323a0cdb2b95a55d764f23", size = 98146 } +sdist = { url = "https://files.pythonhosted.org/packages/75/03/bdf296a9bc2cac63f487cd9d3a64dee3f5df87f5b77c92189f8fbc5dd99d/groundlight-0.21.1.tar.gz", hash = "sha256:f6b0edcb1f317f4b40620e210230adfcc89f5ee8822edb2f7327534862d003dd", size = 97503 } wheels = [ - { url = "https://files.pythonhosted.org/packages/99/82/3d8a3aa61767abc4890be31c6bf8c784a79891bf04066b35fa99458af40f/groundlight-0.20.0-py3-none-any.whl", hash = "sha256:1a5ee7f6c0b45d0293a0cc6437dcf68f7681b753d4c6709179fb5d9715dbace0", size = 253738 }, + { url = "https://files.pythonhosted.org/packages/3a/7e/04a9b383c4073d87a891887c9773bdfc2a4debf28acb4ac030c197d16b15/groundlight-0.21.1-py3-none-any.whl", hash = "sha256:b8f2c31a0af4e51016719f4b7dc825fc5cb7ba9fb46a0c965d0876d723d6a4dd", size = 253120 }, ] [[package]] @@ -937,8 +937,8 @@ dev = [ [package.metadata] requires-dist = [ - { name = "framegrab", specifier = ">=0.8.0" }, - { name = "groundlight", specifier = ">=0.20.0" }, + { name = "framegrab", specifier = ">=0.9.0" }, + { name = "groundlight", specifier = ">=0.21.0" }, { name = "jsonformatter", specifier = ">=0.3.2" }, { name = "numpy", specifier = "<2.0.0" }, { name = "opencv-python-headless", specifier = ">=4.10.0.84" }, From 538b186bde982bf1e18a39445d4bb67bcf3df711 Mon Sep 17 00:00:00 2001 From: Tyler Romero Date: Tue, 17 Dec 2024 16:02:08 -0800 Subject: [PATCH 6/6] Update readme --- README.md | 40 +++++++++++++++++++++------------------- 1 file changed, 21 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 4c109f4..ba9e487 100644 --- a/README.md +++ b/README.md @@ -32,43 +32,45 @@ usage: python -m stream -t TOKEN -d DETECTOR [options] Groundlight Stream Processor -A command-line tool that captures frames from a video source and sends them to a Groundlight detector for analysis. +Captures frames from video sources and sends them to Groundlight detectors for analysis. -Supports a variety of input sources including: -- Video devices (usb cameras, webcams, etc) +Supported input sources: +- USB cameras and webcams - RTSP streams - YouTube Live streams - HLS streams -- Image directories -- Video files (mp4, etc) -- Image URLs +- Video files (mp4, mov, mjpeg) options: -h, --help show this help message and exit - -t, --token TOKEN Groundlight API token for authentication. - -d, --detector DETECTOR + -t TOKEN, --token TOKEN + Groundlight API token for authentication. + -d DETECTOR, --detector DETECTOR Detector ID to send ImageQueries to. - -e, --endpoint ENDPOINT + -e ENDPOINT, --endpoint ENDPOINT API endpoint to target. For example, could be pointed at an edge-endpoint proxy server (https://github.com/groundlight/edge-endpoint). - -s, --stream STREAM Video source. A device ID, filename, or URL. Defaults to device ID '0'. - -x, --streamtype {infer,device,directory,rtsp,youtube,file,image_url} + -s STREAM, --stream STREAM + Video source. A device ID, filename, or URL. Defaults to device ID '0'. + -x {infer,device,directory,rtsp,youtube,file,image_url}, --streamtype {infer,device,directory,rtsp,youtube,file,image_url} Source type. Defaults to 'infer' which will attempt to set this value based on --stream. - -f, --fps FPS Frames per second to capture (0 for max rate). Defaults to 1 FPS. + -f FPS, --fps FPS Frames per second to capture (0 for max rate). Defaults to 1 FPS. -v, --verbose Enable debug logging. -m, --motion Enables motion detection, which is disabled by default. - -r, --threshold THRESHOLD - Motion detection threshold (% pixels changed). Defaults to 1%. - -p, --postmotion POSTMOTION + -r MOTION_PIXEL_THRESHOLD, --motion_pixel_threshold MOTION_PIXEL_THRESHOLD + Motion detection pixel threshold (% pixels changed). Defaults to 1%. + -b MOTION_VAL_THRESHOLD, --motion_val_threshold MOTION_VAL_THRESHOLD + Motion detection value threshold (degree of change). Defaults to 20. + -p POSTMOTION, --postmotion POSTMOTION Seconds to capture after motion detected. Defaults to 1 second. - -i, --maxinterval MAXINTERVAL + -i MAXINTERVAL, --maxinterval MAXINTERVAL Max seconds between frames even without motion. Defaults to 1000 seconds. -k, --keep-connection-open Keep connection open for low-latency frame grabbing (uses more CPU and network bandwidth). Defaults to false. - -w, --width RESIZE_WIDTH + -w RESIZE_WIDTH, --width RESIZE_WIDTH Resize width in pixels. - -y, --height RESIZE_HEIGHT + -y RESIZE_HEIGHT, --height RESIZE_HEIGHT Resize height in pixels. - -c, --crop CROP Crop region, specified as fractions (0-1) of each dimension (e.g. '0.25,0.2,0.8,0.9'). + -c CROP, --crop CROP Crop region, specified as fractions (0-1) of each dimension (e.g. '0.25,0.2,0.8,0.9'). ``` Start sending frames and getting predictions and labels using your own API token and detector ID: