From 00dcb152b399e761252d91d83e9106b4e7624a98 Mon Sep 17 00:00:00 2001 From: t-sasatani Date: Mon, 24 Nov 2025 17:36:30 -0800 Subject: [PATCH 01/31] initial structure for usbcam stuff --- mio/behavior_cam.py | 263 +++++++++++++++++++++++++ mio/cli/main.py | 2 + mio/cli/usbcam.py | 68 +++++++ mio/data/config/camera/usbcam_mbp.yaml | 9 + mio/devices/usbcam.py | 39 ++++ mio/models/usbcam.py | 46 +++++ mio/stream_daq.py | 16 +- mio/utils.py | 25 ++- 8 files changed, 453 insertions(+), 15 deletions(-) create mode 100644 mio/behavior_cam.py create mode 100644 mio/cli/usbcam.py create mode 100644 mio/data/config/camera/usbcam_mbp.yaml create mode 100644 mio/devices/usbcam.py create mode 100644 mio/models/usbcam.py diff --git a/mio/behavior_cam.py b/mio/behavior_cam.py new file mode 100644 index 00000000..0b103db9 --- /dev/null +++ b/mio/behavior_cam.py @@ -0,0 +1,263 @@ +""" +Behavior camera capture using multiprocessing for USB cameras. +""" + +import multiprocessing +import os +import queue +import time +from pathlib import Path +from typing import Optional, Union + +import cv2 + +from mio import init_logger +from mio.io import BufferedCSVWriter, VideoWriter +from mio.models.usbcam import USBCameraRecordingConfig +from mio.types import ConfigSource +from mio.utils import exact_iter + +# Constants +FPS_LOG_INTERVAL_SECONDS = 5.0 +FRAME_QUEUE_MAXSIZE = 30 +CSV_BUFFER_SIZE = 100 +QUEUE_TIMEOUT_SECONDS = 1.0 + + +class BehaviorCam: + """ + Behavior camera capture class using multiprocessing. + + Separates camera interface from capture/writing logic using multiprocessing. + Similar architecture to :class:`.StreamDaq`. + """ + + def __init__( + self, + recording_config: Union[USBCameraRecordingConfig, ConfigSource], + ) -> None: + """ + Initialize behavior camera capture. + + Args: + recording_config: Configuration object, config ID, or path to config file + """ + self.logger = init_logger("behaviorCam") + self.config = USBCameraRecordingConfig.from_any(recording_config) + self.terminate: multiprocessing.Event = multiprocessing.Event() + + def _camera_recv( + self, + frame_queue: multiprocessing.Queue, + ) -> None: + """ + Read frames from camera and put them in the queue. + + This runs in a separate process to decouple camera I/O from writing. + + Args: + frame_queue: Queue to put frames into + """ + locallogs = init_logger("behaviorCam.camera_recv") + + cap = cv2.VideoCapture(self.config.camera_index) + if not cap.isOpened(): + frame_queue.put(None) + raise RuntimeError(f"Failed to open camera at index {self.config.camera_index}") + + cap.set(cv2.CAP_PROP_FRAME_WIDTH, self.config.frame_width) + cap.set(cv2.CAP_PROP_FRAME_HEIGHT, self.config.frame_height) + cap.set(cv2.CAP_PROP_FPS, self.config.fps) + + locallogs.info("Camera opened, starting frame capture") + + try: + while not self.terminate.is_set(): + ret, frame = cap.read() + if ret: + # Get timestamp for this frame (float unix time in seconds) + unix_time = time.time() + try: + frame_queue.put( + (frame, unix_time), + block=True, + timeout=QUEUE_TIMEOUT_SECONDS, + ) + except queue.Full: + locallogs.warning("Frame queue full, skipping frame") + else: + locallogs.warning("Failed to read frame from camera") + time.sleep(0.01) # Small delay before retry + finally: + cap.release() + locallogs.debug("Camera released, putting sentinel in queue") + try: + frame_queue.put(None, block=True, timeout=QUEUE_TIMEOUT_SECONDS) + except queue.Full: + locallogs.error("Frame queue full, could not put sentinel") + + def capture( + self, + output_dir: Optional[str] = None, + show_video: bool = True, + ) -> None: + """ + Start frame capture and recording. + + Args: + output_dir: Output directory (defaults to config.output_dir) + show_video: If True, display video preview window + """ + self.terminate.clear() + + output_dir = output_dir or self.config.output_dir + os.makedirs(output_dir, exist_ok=True) + + # Create video writer with Unix timestamp filename + timestamp = int(time.time()) # seconds (for filename) + video_path = Path(output_dir) / f"{timestamp}.avi" + csv_path = Path(output_dir) / f"{timestamp}.csv" + + # Get actual resolution and fps (may differ from requested) + # We'll get these from the first frame + actual_fps = self.config.fps + actual_width = self.config.frame_width + actual_height = self.config.frame_height + + # Determine pixel format from config or auto-detect based on codec + if self.config.pix_fmt is not None: + pix_fmt = self.config.pix_fmt + elif self.config.codec.lower() == "mjpeg": + pix_fmt = "yuvj420p" # YUV color format for MJPEG + elif self.config.codec.lower() == "rawvideo": + pix_fmt = "gray" # Grayscale for rawvideo + else: + pix_fmt = "yuv420p" # Default YUV format for other codecs + + writer = VideoWriter( + path=video_path, + fps=actual_fps, + output_dict={ + "-vcodec": self.config.codec, + "-f": "avi", + "-pix_fmt": pix_fmt, + }, + ) + + csv_writer = BufferedCSVWriter( + file_path=csv_path, + header=["frame_index", "unix_time"], + buffer_size=CSV_BUFFER_SIZE, + ) + + shared_resource_manager = multiprocessing.Manager() + frame_queue = shared_resource_manager.Queue(maxsize=FRAME_QUEUE_MAXSIZE) + + p_camera = multiprocessing.Process( + target=self._camera_recv, + args=(frame_queue,), + name="camera_recv", + ) + + p_camera.start() + + self.logger.info(f"Recording to {video_path}") + self.logger.info("Press Ctrl+C to stop recording") + + frames_written = 0 + frame_index = 0 + first_frame = True + start_time = time.time() + last_fps_log_time = start_time + frames_in_window = 0 + + try: + for frame_data in exact_iter(frame_queue.get, None): + if frame_data is None: + break + + frame, unix_time = frame_data + + # Get actual dimensions from first frame + if first_frame: + actual_height, actual_width = frame.shape[:2] + self.logger.info( + f"Resolution: {actual_width}x{actual_height} @ {actual_fps}fps" + ) + first_frame = False + + # Convert frame based on codec + if self.config.codec.lower() == "rawvideo": + # Grayscale for rawvideo + if len(frame.shape) == 3: + frame_out = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) + else: + frame_out = frame + else: + # RGB for color codecs (mjpeg, libx264, etc.) + if len(frame.shape) == 3: + frame_out = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) + else: + # If grayscale, convert to RGB + frame_out = cv2.cvtColor(frame, cv2.COLOR_GRAY2RGB) + + # Write frame to video + writer.write_frame(frame_out) + + # Write frame metadata to CSV + csv_writer.append( + { + "frame_index": frame_index, + "unix_time": unix_time, + } + ) + + frames_written += 1 + frame_index += 1 + frames_in_window += 1 + + # Log FPS at regular intervals (FPS for the last window) + current_time = time.time() + window_elapsed = current_time - last_fps_log_time + if window_elapsed >= FPS_LOG_INTERVAL_SECONDS: + fps = frames_in_window / window_elapsed + total_elapsed = current_time - start_time + self.logger.info( + f"FPS: {fps:.2f} | Frames: {frames_written} | " + f"Time: {total_elapsed:.1f}s" + ) + last_fps_log_time = current_time + frames_in_window = 0 + + # Show preview + if show_video: + try: + cv2.imshow("Recording", frame) + cv2.waitKey(1) + except cv2.error as e: + self.logger.exception(f"Error displaying frame: {e}") + + except KeyboardInterrupt: + self.logger.info("Recording stopped by user (Ctrl+C)") + self.terminate.set() + except Exception as e: + self.logger.exception(f"Error during capture: {e}") + self.terminate.set() + finally: + # Wait for camera process to finish + self.terminate.set() + p_camera.join(timeout=5) + if p_camera.is_alive(): + self.logger.warning("Termination timeout: force terminating camera process") + p_camera.terminate() + p_camera.join() + + # Close writers + writer.close() + csv_writer.close() + + if show_video: + cv2.destroyAllWindows() + cv2.waitKey(100) + + self.logger.info(f"Saved recording to {video_path} ({frames_written} frames written)") diff --git a/mio/cli/main.py b/mio/cli/main.py index 0c3d2834..0d2058ad 100644 --- a/mio/cli/main.py +++ b/mio/cli/main.py @@ -8,6 +8,7 @@ from mio.cli.process import process from mio.cli.stream import stream from mio.cli.update import device, update +from mio.cli.usbcam import usbcam from mio.cli.util import hash @@ -27,3 +28,4 @@ def cli(ctx: click.Context) -> None: cli.add_command(config) cli.add_command(process) cli.add_command(hash) +cli.add_command(usbcam) diff --git a/mio/cli/usbcam.py b/mio/cli/usbcam.py new file mode 100644 index 00000000..4a5b835d --- /dev/null +++ b/mio/cli/usbcam.py @@ -0,0 +1,68 @@ +""" +CLI commands for recording video from USB camera. +""" + +from typing import Optional + +import click + +from mio.behavior_cam import BehaviorCam +from mio.cli.common import ConfigIDOrPath +from mio.devices.usbcam import ELPUVCCamera +from mio.models.usbcam import USBCameraRecordingConfig + + +@click.group() +def usbcam() -> None: + """ + Command group for USB Camera + """ + pass + + +@usbcam.command() +@click.option( + "-c", + "--config", + required=True, + type=ConfigIDOrPath(), + help=( + "Either a config `id` or a path to USB camera config YAML file. " + "If path is relative, treated as relative to the current directory, " + "and then if no matching file is found, relative to the user `config_dir` " + "(see `mio config --help`)." + ), +) +@click.option( + "-o", + "--output-dir", + type=click.Path(), + help="Override output directory from config (optional)", +) +def record(config: str, output_dir: Optional[str]) -> None: + """Record video with Unix timestamp filename""" + recording_config = USBCameraRecordingConfig.from_any(config) + + # Override output_dir if provided via CLI + if output_dir is not None: + recording_config.output_dir = output_dir + + behavior_cam = BehaviorCam(recording_config=recording_config) + try: + behavior_cam.capture(output_dir=output_dir) + except Exception as e: + click.echo(f"Error recording video: {e}", err=True) + raise click.ClickException(f"Error recording video: {e}") from e + + +@usbcam.command() +def list_cameras() -> None: + """List available cameras""" + cameras = ELPUVCCamera.list_cameras() + if not cameras: + click.echo("No cameras found") + return + + click.echo("Available cameras:") + for idx, info in cameras.items(): + click.echo(f" Index {idx}: {info['resolution']} @ {info['fps']} fps") diff --git a/mio/data/config/camera/usbcam_mbp.yaml b/mio/data/config/camera/usbcam_mbp.yaml new file mode 100644 index 00000000..67b4536f --- /dev/null +++ b/mio/data/config/camera/usbcam_mbp.yaml @@ -0,0 +1,9 @@ +id: default-camera +mio_model: mio.models.usbcam.USBCameraRecordingConfig +mio_version: 0.8.2.dev13+g338e847.d20251030 +camera_index: 0 +output_dir: recordings +frame_width: 1920 +frame_height: 1080 +fps: 30 +format: MJPEG diff --git a/mio/devices/usbcam.py b/mio/devices/usbcam.py new file mode 100644 index 00000000..86102c7b --- /dev/null +++ b/mio/devices/usbcam.py @@ -0,0 +1,39 @@ +""" +USB Camera device implementation. +""" + +from typing import Dict + +import cv2 + + +class ELPUVCCamera: + """USB Camera device for listing cameras.""" + + @staticmethod + def list_cameras() -> Dict[int, Dict[str, str]]: + """ + List available cameras with details. + + Returns: + Dictionary mapping camera index to camera info + """ + available_cameras: Dict[int, Dict[str, str]] = {} + + for i in range(10): + cap = cv2.VideoCapture(i) + if cap.isOpened(): + ret, frame = cap.read() + if ret: + resolution = f"{frame.shape[1]}x{frame.shape[0]}" + fps = int(cap.get(cv2.CAP_PROP_FPS)) + available_cameras[i] = { + "resolution": resolution, + "fps": str(fps), + } + cap.release() + else: + # Stop checking after first failure (no more cameras) + break + + return available_cameras diff --git a/mio/models/usbcam.py b/mio/models/usbcam.py new file mode 100644 index 00000000..6eaadf17 --- /dev/null +++ b/mio/models/usbcam.py @@ -0,0 +1,46 @@ +""" +Models for USB camera recording configuration. +""" + +from typing import Optional + +from pydantic import Field + +from mio.models import MiniscopeConfig +from mio.models.mixins import ConfigYAMLMixin + + +class USBCameraRecordingConfig(MiniscopeConfig, ConfigYAMLMixin): + """ + Configuration for recording video from USB camera. + """ + + camera_index: int = Field(default=0, description="Index of the camera to use.") + output_dir: str = Field( + default="recordings", description="Directory to save the recorded video." + ) + frame_width: int = Field(default=1920, description="Width of the recorded video.") + frame_height: int = Field(default=1080, description="Height of the recorded video.") + fps: int = Field(default=20, description="Frames per second of the recorded video.") + format: str = Field( + default="MJPEG", + description="Video format for camera capture (e.g., MJPEG, YUY2). " + "Note: Output video encoding is handled by VideoWriter.", + ) + codec: str = Field( + default="mjpeg", + description="Video codec for output file (e.g., mjpeg, libx264, rawvideo).", + ) + pix_fmt: Optional[str] = Field( + default=None, + description="Pixel format for video encoding (e.g., yuvj420p, yuv420p, gray). " + "If None, automatically determined from codec.", + ) + vendor_id: int = Field( + default=0x32E4, + description="USB vendor ID of the camera (for reference/documentation).", + ) + product_id: Optional[int] = Field( + default=None, + description="USB product ID of the camera (for reference/documentation).", + ) diff --git a/mio/stream_daq.py b/mio/stream_daq.py index 7fbe4b9f..05284bf7 100644 --- a/mio/stream_daq.py +++ b/mio/stream_daq.py @@ -10,7 +10,7 @@ import time from collections.abc import Iterator from pathlib import Path -from typing import Any, Callable, Generator, List, Literal, Optional, Tuple, Union +from typing import Generator, List, Literal, Optional, Tuple, Union import cv2 import numpy as np @@ -31,6 +31,7 @@ from mio.plots.headers import StreamPlotter from mio.process.frame_helper import FrequencyMaskHelper from mio.types import ConfigSource +from mio.utils import exact_iter HAVE_OK = False ok_error = None @@ -49,19 +50,6 @@ ) -def exact_iter(f: Callable, sentinel: Any) -> Generator[Any, None, None]: - """ - A version of :func:`iter` that compares with `is` rather than `==` - because truth value of numpy arrays is ambiguous. - """ - while True: - val = f() - if val is sentinel: - break - else: - yield val - - class StreamDaq: """ A combined class for configuring and reading frames from a UART and FPGA source. diff --git a/mio/utils.py b/mio/utils.py index abc05ff1..9e717410 100644 --- a/mio/utils.py +++ b/mio/utils.py @@ -3,14 +3,37 @@ """ import hashlib +from collections.abc import Callable from pathlib import Path -from typing import TYPE_CHECKING, Union +from typing import TYPE_CHECKING, Generator, TypeVar, Union import cv2 if TYPE_CHECKING: pass +T = TypeVar("T") + + +def exact_iter(f: Callable[[], T], sentinel: T) -> Generator[T, None, None]: + """ + A version of :func:`iter` that compares with `is` rather than `==` + because truth value of numpy arrays is ambiguous. + + Args: + f: Function to call repeatedly + sentinel: Sentinel value to stop iteration when `f()` returns this (compared with `is`) + + Yields: + Values from `f()` until sentinel is encountered + """ + while True: + val = f() + if val is sentinel: + break + else: + yield val + def hash_file(path: Union[Path, str]) -> str: """ From 41a6a4a110f3c7ad6ccd524607c7db4ccf04dba8 Mon Sep 17 00:00:00 2001 From: t-sasatani Date: Tue, 25 Nov 2025 14:43:18 -0800 Subject: [PATCH 02/31] do ELP cam, isolate common stuff to helper --- mio/behavior_cam.py | 69 +- mio/cli/usbcam.py | 25 +- mio/data/config/camera/elp-camera.yaml | 8 + mio/devices/usbcam.py | 204 ++++- mio/models/usbcam.py | 1 - pdm.lock | 1063 +++++++++++++++++++++--- pyproject.toml | 1 + 7 files changed, 1208 insertions(+), 163 deletions(-) create mode 100644 mio/data/config/camera/elp-camera.yaml diff --git a/mio/behavior_cam.py b/mio/behavior_cam.py index 0b103db9..1e7af522 100644 --- a/mio/behavior_cam.py +++ b/mio/behavior_cam.py @@ -12,14 +12,14 @@ import cv2 from mio import init_logger +from mio.devices.usbcam import convert_frame_for_codec, determine_pix_fmt, open_camera from mio.io import BufferedCSVWriter, VideoWriter from mio.models.usbcam import USBCameraRecordingConfig from mio.types import ConfigSource from mio.utils import exact_iter -# Constants -FPS_LOG_INTERVAL_SECONDS = 5.0 -FRAME_QUEUE_MAXSIZE = 30 +FPS_LOG_INTERVAL_SECONDS = 10.0 +FRAME_QUEUE_MAXSIZE = 100 CSV_BUFFER_SIZE = 100 QUEUE_TIMEOUT_SECONDS = 1.0 @@ -35,15 +35,18 @@ class BehaviorCam: def __init__( self, recording_config: Union[USBCameraRecordingConfig, ConfigSource], + camera_index: int, ) -> None: """ Initialize behavior camera capture. Args: recording_config: Configuration object, config ID, or path to config file + camera_index: Index of the camera to use """ self.logger = init_logger("behaviorCam") self.config = USBCameraRecordingConfig.from_any(recording_config) + self.camera_index = camera_index self.terminate: multiprocessing.Event = multiprocessing.Event() def _camera_recv( @@ -60,14 +63,16 @@ def _camera_recv( """ locallogs = init_logger("behaviorCam.camera_recv") - cap = cv2.VideoCapture(self.config.camera_index) - if not cap.isOpened(): + try: + cap = open_camera( + camera_index=self.camera_index, + frame_width=self.config.frame_width, + frame_height=self.config.frame_height, + fps=self.config.fps, + ) + except RuntimeError: frame_queue.put(None) - raise RuntimeError(f"Failed to open camera at index {self.config.camera_index}") - - cap.set(cv2.CAP_PROP_FRAME_WIDTH, self.config.frame_width) - cap.set(cv2.CAP_PROP_FRAME_HEIGHT, self.config.frame_height) - cap.set(cv2.CAP_PROP_FPS, self.config.fps) + raise locallogs.info("Camera opened, starting frame capture") @@ -125,14 +130,7 @@ def capture( actual_height = self.config.frame_height # Determine pixel format from config or auto-detect based on codec - if self.config.pix_fmt is not None: - pix_fmt = self.config.pix_fmt - elif self.config.codec.lower() == "mjpeg": - pix_fmt = "yuvj420p" # YUV color format for MJPEG - elif self.config.codec.lower() == "rawvideo": - pix_fmt = "gray" # Grayscale for rawvideo - else: - pix_fmt = "yuv420p" # Default YUV format for other codecs + pix_fmt = determine_pix_fmt(self.config.codec, self.config.pix_fmt) writer = VideoWriter( path=video_path, @@ -170,10 +168,17 @@ def capture( start_time = time.time() last_fps_log_time = start_time frames_in_window = 0 + writer_used = False try: for frame_data in exact_iter(frame_queue.get, None): if frame_data is None: + # Early termination signal from camera process (camera failed) + if frames_written == 0: + raise RuntimeError( + "Camera failed to initialize or read frames. " + "Please check camera connection and settings." + ) break frame, unix_time = frame_data @@ -187,22 +192,11 @@ def capture( first_frame = False # Convert frame based on codec - if self.config.codec.lower() == "rawvideo": - # Grayscale for rawvideo - if len(frame.shape) == 3: - frame_out = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) - else: - frame_out = frame - else: - # RGB for color codecs (mjpeg, libx264, etc.) - if len(frame.shape) == 3: - frame_out = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) - else: - # If grayscale, convert to RGB - frame_out = cv2.cvtColor(frame, cv2.COLOR_GRAY2RGB) + frame_out = convert_frame_for_codec(frame, self.config.codec) # Write frame to video writer.write_frame(frame_out) + writer_used = True # Write frame metadata to CSV csv_writer.append( @@ -252,8 +246,17 @@ def capture( p_camera.terminate() p_camera.join() - # Close writers - writer.close() + # Close writers (only if used) + if writer_used: + try: + writer.close() + except AttributeError as e: + # FFmpegWriter may not have _proc if no frames were written + self.logger.warning(f"Error closing video writer: {e}") + else: + # Remove empty video file if no frames were written + if video_path.exists(): + video_path.unlink() csv_writer.close() if show_video: diff --git a/mio/cli/usbcam.py b/mio/cli/usbcam.py index 4a5b835d..35af2404 100644 --- a/mio/cli/usbcam.py +++ b/mio/cli/usbcam.py @@ -8,7 +8,8 @@ from mio.behavior_cam import BehaviorCam from mio.cli.common import ConfigIDOrPath -from mio.devices.usbcam import ELPUVCCamera +from mio.devices.usbcam import format_camera_info +from mio.devices.usbcam import list_cameras as list_available_cameras from mio.models.usbcam import USBCameraRecordingConfig @@ -47,7 +48,23 @@ def record(config: str, output_dir: Optional[str]) -> None: if output_dir is not None: recording_config.output_dir = output_dir - behavior_cam = BehaviorCam(recording_config=recording_config) + # Get available cameras and always prompt for selection + cameras = list_available_cameras() + if not cameras: + raise click.ClickException("No cameras found. Please connect a camera and try again.") + + click.echo("Available cameras:") + for idx, info in cameras.items(): + click.echo(f" {format_camera_info(idx, info)}") + + selected_index = click.prompt( + "Select camera index", + type=click.Choice([str(idx) for idx in cameras], case_sensitive=False), + default=str(min(cameras.keys())), + ) + camera_index = int(selected_index) + + behavior_cam = BehaviorCam(recording_config=recording_config, camera_index=camera_index) try: behavior_cam.capture(output_dir=output_dir) except Exception as e: @@ -58,11 +75,11 @@ def record(config: str, output_dir: Optional[str]) -> None: @usbcam.command() def list_cameras() -> None: """List available cameras""" - cameras = ELPUVCCamera.list_cameras() + cameras = list_available_cameras() if not cameras: click.echo("No cameras found") return click.echo("Available cameras:") for idx, info in cameras.items(): - click.echo(f" Index {idx}: {info['resolution']} @ {info['fps']} fps") + click.echo(f" {format_camera_info(idx, info, prefix='Index ')}") diff --git a/mio/data/config/camera/elp-camera.yaml b/mio/data/config/camera/elp-camera.yaml new file mode 100644 index 00000000..81506933 --- /dev/null +++ b/mio/data/config/camera/elp-camera.yaml @@ -0,0 +1,8 @@ +id: elp-camera +mio_model: mio.models.usbcam.USBCameraRecordingConfig +mio_version: 0.8.2.dev13+g338e847.d20251030 +output_dir: user_data/recordings +frame_width: 1920 +frame_height: 1080 +fps: 20 +format: MJPEG diff --git a/mio/devices/usbcam.py b/mio/devices/usbcam.py index 86102c7b..4aa4987a 100644 --- a/mio/devices/usbcam.py +++ b/mio/devices/usbcam.py @@ -1,39 +1,195 @@ """ -USB Camera device implementation. +USB Camera device helper functions. """ -from typing import Dict +import time +from typing import Dict, Optional import cv2 +import numpy as np +from cv2_enumerate_cameras import enumerate_cameras +# Constants +MAX_CAMERA_INDEX = 5 +CAMERA_INIT_DELAY_SECONDS = 0.1 # Delay after setting camera properties before reading +CAMERA_INIT_RETRY_ATTEMPTS = 3 # Number of retry attempts when reading initial frame -class ELPUVCCamera: - """USB Camera device for listing cameras.""" - @staticmethod - def list_cameras() -> Dict[int, Dict[str, str]]: - """ - List available cameras with details. +def determine_pix_fmt(codec: str, pix_fmt: Optional[str] = None) -> str: + """ + Determine pixel format for video output based on codec. - Returns: - Dictionary mapping camera index to camera info - """ - available_cameras: Dict[int, Dict[str, str]] = {} + Args: + codec: Video codec (e.g., "mjpeg", "rawvideo", "libx264") + pix_fmt: Explicit pixel format override (if provided, returns this) - for i in range(10): - cap = cv2.VideoCapture(i) - if cap.isOpened(): - ret, frame = cap.read() - if ret: - resolution = f"{frame.shape[1]}x{frame.shape[0]}" - fps = int(cap.get(cv2.CAP_PROP_FPS)) + Returns: + Pixel format string for FFmpeg + """ + if pix_fmt is not None: + return pix_fmt + elif codec.lower() == "mjpeg": + return "yuvj420p" # YUV color format for MJPEG + elif codec.lower() == "rawvideo": + return "gray" # Grayscale for rawvideo + else: + return "yuv420p" # Default YUV format for other codecs + + +def convert_frame_for_codec(frame: np.ndarray, codec: str) -> np.ndarray: + """ + Convert frame color space based on codec requirements. + + Args: + frame: Input frame (BGR from OpenCV) + codec: Video codec (e.g., "mjpeg", "rawvideo", "libx264") + + Returns: + Converted frame ready for video writer + """ + if codec.lower() == "rawvideo": + # Rawvideo expects grayscale + if len(frame.shape) == 3: + return cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) + else: + return frame + else: + # Other codecs expect RGB + if len(frame.shape) == 3: + return cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) + else: + # If grayscale, convert to RGB + return cv2.cvtColor(frame, cv2.COLOR_GRAY2RGB) + + +def open_camera( + camera_index: int, + frame_width: int, + frame_height: int, + fps: int, +) -> cv2.VideoCapture: + """ + Open and configure a camera with the specified settings. + + Args: + camera_index: Index of the camera to open + frame_width: Desired frame width + frame_height: Desired frame height + fps: Desired frames per second + + Returns: + Configured VideoCapture object + + Raises: + RuntimeError: If camera cannot be opened or cannot read frames + """ + cap = cv2.VideoCapture(camera_index) + if not cap.isOpened(): + raise RuntimeError(f"Failed to open camera at index {camera_index}") + + cap.set(cv2.CAP_PROP_FRAME_WIDTH, frame_width) + cap.set(cv2.CAP_PROP_FRAME_HEIGHT, frame_height) + cap.set(cv2.CAP_PROP_FPS, fps) + + # Give camera time to initialize after setting properties + time.sleep(CAMERA_INIT_DELAY_SECONDS) + + # Verify camera is working by reading a test frame + # Retry a few times as some cameras need a moment to start + ret = False + frame = None + for _ in range(CAMERA_INIT_RETRY_ATTEMPTS): + ret, frame = cap.read() + if ret: + break + time.sleep(CAMERA_INIT_DELAY_SECONDS) + + if not ret: + cap.release() + raise RuntimeError( + f"Camera at index {camera_index} opened but could not read initial frame " + f"after {CAMERA_INIT_RETRY_ATTEMPTS} attempts. " + "The camera may be in use by another application." + ) + + return cap + + +def format_camera_info(idx: int, info: Dict[str, str], prefix: str = "[") -> str: + """ + Format camera information for display. + + Args: + idx: Camera index + info: Camera info dictionary + prefix: Prefix for index (default: "[" for "[0]", + use "Index " for "Index 0:" format) + + Returns: + Formatted string for display + """ + name = info.get("name", "Camera") + resolution = info.get("resolution", "Unknown") + fps = info.get("fps", "Unknown") + index_str = f"[{idx}]" if prefix == "[" else f"{prefix}{idx}:" + return f"{index_str} {name} - {resolution} @ {fps} fps" + + +def list_cameras() -> Dict[int, Dict[str, str]]: + """ + List available cameras with name, resolution, and fps. + + Returns: + Dictionary mapping camera index (0, 1, 2...) to camera info. + Prefers standard indices (0, 1, 2...) over backend-specific high indices. + """ + # Get camera names from cv2-enumerate-cameras + enumerated_cameras: Dict[int, str] = {} + try: + for camera in enumerate_cameras(): + enumerated_cameras[camera.index] = camera.name + except Exception: + pass + + # First, check standard indices (0-9) - prefer these over high backend indices + available_cameras: Dict[int, Dict[str, str]] = {} + found_cameras: Dict[tuple[str, str, str], int] = {} # (resolution, fps, name) -> index + + for i in range(MAX_CAMERA_INDEX): + cap = cv2.VideoCapture(i) + if cap.isOpened(): + ret, frame = cap.read() + if ret: + resolution = f"{frame.shape[1]}x{frame.shape[0]}" + fps = int(cap.get(cv2.CAP_PROP_FPS)) + camera_key = (resolution, str(fps)) + + # Try to find matching name from enumerated cameras + name = f"Camera {i}" + for enum_idx, enum_name in enumerated_cameras.items(): + # Check if this standard index matches an enumerated camera + test_cap = cv2.VideoCapture(enum_idx) + if test_cap.isOpened(): + test_ret, test_frame = test_cap.read() + test_cap.release() + if test_ret: + test_res = f"{test_frame.shape[1]}x{test_frame.shape[0]}" + test_fps = int(cv2.VideoCapture(enum_idx).get(cv2.CAP_PROP_FPS)) + if test_res == resolution and str(test_fps) == str(fps): + name = enum_name + break + + # Check if we've already found this camera (duplicate) + if camera_key not in found_cameras: + found_cameras[camera_key] = i available_cameras[i] = { + "name": name, "resolution": resolution, "fps": str(fps), } - cap.release() - else: - # Stop checking after first failure (no more cameras) - break + cap.release() + else: + # Stop checking after first failure (no more cameras) + break - return available_cameras + return available_cameras diff --git a/mio/models/usbcam.py b/mio/models/usbcam.py index 6eaadf17..bf4a2e33 100644 --- a/mio/models/usbcam.py +++ b/mio/models/usbcam.py @@ -15,7 +15,6 @@ class USBCameraRecordingConfig(MiniscopeConfig, ConfigYAMLMixin): Configuration for recording video from USB camera. """ - camera_index: int = Field(default=0, description="Index of the camera to use.") output_dir: str = Field( default="recordings", description="Directory to save the recorded video." ) diff --git a/pdm.lock b/pdm.lock index 024b72f2..6e40aa8f 100644 --- a/pdm.lock +++ b/pdm.lock @@ -5,7 +5,7 @@ groups = ["default", "all", "dev", "docs", "plot", "tests"] strategy = ["inherit_metadata"] lock_version = "4.5.0" -content_hash = "sha256:1c7e24d17e4e83008e0d47da11b1d3738e2d1201e2a7a91f637be480a94bd494" +content_hash = "sha256:89d1f14275ac2a17522e3e04db4391ba8d908abc769f728256dd12f20bf4f4d7" [[metadata.targets]] requires_python = "~=3.10" @@ -34,6 +34,10 @@ requires_python = ">=3.10" summary = "A light, configurable Sphinx theme" groups = ["all", "docs"] marker = "python_version ~= \"3.10\"" +files = [ + {file = "alabaster-1.0.0-py3-none-any.whl", hash = "sha256:fc6786402dc3fcb2de3cabd5fe455a2db534b371124f1f21de8731783dec828b"}, + {file = "alabaster-1.0.0.tar.gz", hash = "sha256:c00dca57bca26fa62a6d7d0a9fcce65f3e026e9bfe33e9c538fd3fbb2144fd9e"}, +] [[package]] name = "alabaster" @@ -151,7 +155,7 @@ files = [ [[package]] name = "black" -version = "25.9.0" +version = "25.11.0" requires_python = ">=3.9" summary = "The uncompromising code formatter." groups = ["all", "dev"] @@ -161,17 +165,37 @@ dependencies = [ "packaging>=22.0", "pathspec>=0.9.0", "platformdirs>=2", - "pytokens>=0.1.10", + "pytokens>=0.3.0", "tomli>=1.1.0; python_version < \"3.11\"", "typing-extensions>=4.0.1; python_version < \"3.11\"", ] files = [ - {file = "black-25.9.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:ef69351df3c84485a8beb6f7b8f9721e2009e20ef80a8d619e2d1788b7816d47"}, - {file = "black-25.9.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e3c1f4cd5e93842774d9ee4ef6cd8d17790e65f44f7cdbaab5f2cf8ccf22a823"}, - {file = "black-25.9.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:154b06d618233fe468236ba1f0e40823d4eb08b26f5e9261526fde34916b9140"}, - {file = "black-25.9.0-cp39-cp39-win_amd64.whl", hash = "sha256:e593466de7b998374ea2585a471ba90553283fb9beefcfa430d84a2651ed5933"}, - {file = "black-25.9.0-py3-none-any.whl", hash = "sha256:474b34c1342cdc157d307b56c4c65bce916480c4a8f6551fdc6bf9b486a7c4ae"}, - {file = "black-25.9.0.tar.gz", hash = "sha256:0474bca9a0dd1b51791fcc507a4e02078a1c63f6d4e4ae5544b9848c7adfb619"}, + {file = "black-25.11.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ec311e22458eec32a807f029b2646f661e6859c3f61bc6d9ffb67958779f392e"}, + {file = "black-25.11.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1032639c90208c15711334d681de2e24821af0575573db2810b0763bcd62e0f0"}, + {file = "black-25.11.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0c0f7c461df55cf32929b002335883946a4893d759f2df343389c4396f3b6b37"}, + {file = "black-25.11.0-cp310-cp310-win_amd64.whl", hash = "sha256:f9786c24d8e9bd5f20dc7a7f0cdd742644656987f6ea6947629306f937726c03"}, + {file = "black-25.11.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:895571922a35434a9d8ca67ef926da6bc9ad464522a5fe0db99b394ef1c0675a"}, + {file = "black-25.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cb4f4b65d717062191bdec8e4a442539a8ea065e6af1c4f4d36f0cdb5f71e170"}, + {file = "black-25.11.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d81a44cbc7e4f73a9d6ae449ec2317ad81512d1e7dce7d57f6333fd6259737bc"}, + {file = "black-25.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:7eebd4744dfe92ef1ee349dc532defbf012a88b087bb7ddd688ff59a447b080e"}, + {file = "black-25.11.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:80e7486ad3535636657aa180ad32a7d67d7c273a80e12f1b4bfa0823d54e8fac"}, + {file = "black-25.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6cced12b747c4c76bc09b4db057c319d8545307266f41aaee665540bc0e04e96"}, + {file = "black-25.11.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6cb2d54a39e0ef021d6c5eef442e10fd71fcb491be6413d083a320ee768329dd"}, + {file = "black-25.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:ae263af2f496940438e5be1a0c1020e13b09154f3af4df0835ea7f9fe7bfa409"}, + {file = "black-25.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0a1d40348b6621cc20d3d7530a5b8d67e9714906dfd7346338249ad9c6cedf2b"}, + {file = "black-25.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:51c65d7d60bb25429ea2bf0731c32b2a2442eb4bd3b2afcb47830f0b13e58bfd"}, + {file = "black-25.11.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:936c4dd07669269f40b497440159a221ee435e3fddcf668e0c05244a9be71993"}, + {file = "black-25.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:f42c0ea7f59994490f4dccd64e6b2dd49ac57c7c84f38b8faab50f8759db245c"}, + {file = "black-25.11.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:35690a383f22dd3e468c85dc4b915217f87667ad9cce781d7b42678ce63c4170"}, + {file = "black-25.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:dae49ef7369c6caa1a1833fd5efb7c3024bb7e4499bf64833f65ad27791b1545"}, + {file = "black-25.11.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bd4a22a0b37401c8e492e994bce79e614f91b14d9ea911f44f36e262195fdda"}, + {file = "black-25.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:aa211411e94fdf86519996b7f5f05e71ba34835d8f0c0f03c00a26271da02664"}, + {file = "black-25.11.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a3bb5ce32daa9ff0605d73b6f19da0b0e6c1f8f2d75594db539fdfed722f2b06"}, + {file = "black-25.11.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:9815ccee1e55717fe9a4b924cae1646ef7f54e0f990da39a34fc7b264fcf80a2"}, + {file = "black-25.11.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:92285c37b93a1698dcbc34581867b480f1ba3a7b92acf1fe0467b04d7a4da0dc"}, + {file = "black-25.11.0-cp39-cp39-win_amd64.whl", hash = "sha256:43945853a31099c7c0ff8dface53b4de56c41294fa6783c0441a8b1d9bf668bc"}, + {file = "black-25.11.0-py3-none-any.whl", hash = "sha256:e3f562da087791e96cefcd9dda058380a442ab322a02e222add53736451f604b"}, + {file = "black-25.11.0.tar.gz", hash = "sha256:9a323ac32f5dc75ce7470501b887250be5005a01602e931a15e45593f70f6e08"}, ] [[package]] @@ -220,7 +244,7 @@ files = [ [[package]] name = "click" -version = "8.3.0" +version = "8.3.1" requires_python = ">=3.10" summary = "Composable command line interface toolkit" groups = ["default", "all", "dev", "docs"] @@ -228,6 +252,10 @@ marker = "python_version ~= \"3.10\"" dependencies = [ "colorama; platform_system == \"Windows\"", ] +files = [ + {file = "click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6"}, + {file = "click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a"}, +] [[package]] name = "click" @@ -267,6 +295,65 @@ marker = "python_version ~= \"3.10\"" dependencies = [ "numpy>=1.23", ] +files = [ + {file = "contourpy-1.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ba38e3f9f330af820c4b27ceb4b9c7feee5fe0493ea53a8720f4792667465934"}, + {file = "contourpy-1.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dc41ba0714aa2968d1f8674ec97504a8f7e334f48eeacebcaa6256213acb0989"}, + {file = "contourpy-1.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9be002b31c558d1ddf1b9b415b162c603405414bacd6932d031c5b5a8b757f0d"}, + {file = "contourpy-1.3.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8d2e74acbcba3bfdb6d9d8384cdc4f9260cae86ed9beee8bd5f54fee49a430b9"}, + {file = "contourpy-1.3.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e259bced5549ac64410162adc973c5e2fb77f04df4a439d00b478e57a0e65512"}, + {file = "contourpy-1.3.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad687a04bc802cbe8b9c399c07162a3c35e227e2daccf1668eb1f278cb698631"}, + {file = "contourpy-1.3.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cdd22595308f53ef2f891040ab2b93d79192513ffccbd7fe19be7aa773a5e09f"}, + {file = "contourpy-1.3.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b4f54d6a2defe9f257327b0f243612dd051cc43825587520b1bf74a31e2f6ef2"}, + {file = "contourpy-1.3.2-cp310-cp310-win32.whl", hash = "sha256:f939a054192ddc596e031e50bb13b657ce318cf13d264f095ce9db7dc6ae81c0"}, + {file = "contourpy-1.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:c440093bbc8fc21c637c03bafcbef95ccd963bc6e0514ad887932c18ca2a759a"}, + {file = "contourpy-1.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6a37a2fb93d4df3fc4c0e363ea4d16f83195fc09c891bc8ce072b9d084853445"}, + {file = "contourpy-1.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b7cd50c38f500bbcc9b6a46643a40e0913673f869315d8e70de0438817cb7773"}, + {file = "contourpy-1.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d6658ccc7251a4433eebd89ed2672c2ed96fba367fd25ca9512aa92a4b46c4f1"}, + {file = "contourpy-1.3.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:70771a461aaeb335df14deb6c97439973d253ae70660ca085eec25241137ef43"}, + {file = "contourpy-1.3.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:65a887a6e8c4cd0897507d814b14c54a8c2e2aa4ac9f7686292f9769fcf9a6ab"}, + {file = "contourpy-1.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3859783aefa2b8355697f16642695a5b9792e7a46ab86da1118a4a23a51a33d7"}, + {file = "contourpy-1.3.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:eab0f6db315fa4d70f1d8ab514e527f0366ec021ff853d7ed6a2d33605cf4b83"}, + {file = "contourpy-1.3.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d91a3ccc7fea94ca0acab82ceb77f396d50a1f67412efe4c526f5d20264e6ecd"}, + {file = "contourpy-1.3.2-cp311-cp311-win32.whl", hash = "sha256:1c48188778d4d2f3d48e4643fb15d8608b1d01e4b4d6b0548d9b336c28fc9b6f"}, + {file = "contourpy-1.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:5ebac872ba09cb8f2131c46b8739a7ff71de28a24c869bcad554477eb089a878"}, + {file = "contourpy-1.3.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4caf2bcd2969402bf77edc4cb6034c7dd7c0803213b3523f111eb7460a51b8d2"}, + {file = "contourpy-1.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:82199cb78276249796419fe36b7386bd8d2cc3f28b3bc19fe2454fe2e26c4c15"}, + {file = "contourpy-1.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:106fab697af11456fcba3e352ad50effe493a90f893fca6c2ca5c033820cea92"}, + {file = "contourpy-1.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d14f12932a8d620e307f715857107b1d1845cc44fdb5da2bc8e850f5ceba9f87"}, + {file = "contourpy-1.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:532fd26e715560721bb0d5fc7610fce279b3699b018600ab999d1be895b09415"}, + {file = "contourpy-1.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f26b383144cf2d2c29f01a1e8170f50dacf0eac02d64139dcd709a8ac4eb3cfe"}, + {file = "contourpy-1.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c49f73e61f1f774650a55d221803b101d966ca0c5a2d6d5e4320ec3997489441"}, + {file = "contourpy-1.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3d80b2c0300583228ac98d0a927a1ba6a2ba6b8a742463c564f1d419ee5b211e"}, + {file = "contourpy-1.3.2-cp312-cp312-win32.whl", hash = "sha256:90df94c89a91b7362e1142cbee7568f86514412ab8a2c0d0fca72d7e91b62912"}, + {file = "contourpy-1.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:8c942a01d9163e2e5cfb05cb66110121b8d07ad438a17f9e766317bcb62abf73"}, + {file = "contourpy-1.3.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:de39db2604ae755316cb5967728f4bea92685884b1e767b7c24e983ef5f771cb"}, + {file = "contourpy-1.3.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3f9e896f447c5c8618f1edb2bafa9a4030f22a575ec418ad70611450720b5b08"}, + {file = "contourpy-1.3.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:71e2bd4a1c4188f5c2b8d274da78faab884b59df20df63c34f74aa1813c4427c"}, + {file = "contourpy-1.3.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de425af81b6cea33101ae95ece1f696af39446db9682a0b56daaa48cfc29f38f"}, + {file = "contourpy-1.3.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:977e98a0e0480d3fe292246417239d2d45435904afd6d7332d8455981c408b85"}, + {file = "contourpy-1.3.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:434f0adf84911c924519d2b08fc10491dd282b20bdd3fa8f60fd816ea0b48841"}, + {file = "contourpy-1.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c66c4906cdbc50e9cba65978823e6e00b45682eb09adbb78c9775b74eb222422"}, + {file = "contourpy-1.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8b7fc0cd78ba2f4695fd0a6ad81a19e7e3ab825c31b577f384aa9d7817dc3bef"}, + {file = "contourpy-1.3.2-cp313-cp313-win32.whl", hash = "sha256:15ce6ab60957ca74cff444fe66d9045c1fd3e92c8936894ebd1f3eef2fff075f"}, + {file = "contourpy-1.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:e1578f7eafce927b168752ed7e22646dad6cd9bca673c60bff55889fa236ebf9"}, + {file = "contourpy-1.3.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0475b1f6604896bc7c53bb070e355e9321e1bc0d381735421a2d2068ec56531f"}, + {file = "contourpy-1.3.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:c85bb486e9be652314bb5b9e2e3b0d1b2e643d5eec4992c0fbe8ac71775da739"}, + {file = "contourpy-1.3.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:745b57db7758f3ffc05a10254edd3182a2a83402a89c00957a8e8a22f5582823"}, + {file = "contourpy-1.3.2-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:970e9173dbd7eba9b4e01aab19215a48ee5dd3f43cef736eebde064a171f89a5"}, + {file = "contourpy-1.3.2-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c6c4639a9c22230276b7bffb6a850dfc8258a2521305e1faefe804d006b2e532"}, + {file = "contourpy-1.3.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc829960f34ba36aad4302e78eabf3ef16a3a100863f0d4eeddf30e8a485a03b"}, + {file = "contourpy-1.3.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d32530b534e986374fc19eaa77fcb87e8a99e5431499949b828312bdcd20ac52"}, + {file = "contourpy-1.3.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:e298e7e70cf4eb179cc1077be1c725b5fd131ebc81181bf0c03525c8abc297fd"}, + {file = "contourpy-1.3.2-cp313-cp313t-win32.whl", hash = "sha256:d0e589ae0d55204991450bb5c23f571c64fe43adaa53f93fc902a84c96f52fe1"}, + {file = "contourpy-1.3.2-cp313-cp313t-win_amd64.whl", hash = "sha256:78e9253c3de756b3f6a5174d024c4835acd59eb3f8e2ca13e775dbffe1558f69"}, + {file = "contourpy-1.3.2-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:fd93cc7f3139b6dd7aab2f26a90dde0aa9fc264dbf70f6740d498a70b860b82c"}, + {file = "contourpy-1.3.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:107ba8a6a7eec58bb475329e6d3b95deba9440667c4d62b9b6063942b61d7f16"}, + {file = "contourpy-1.3.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:ded1706ed0c1049224531b81128efbd5084598f18d8a2d9efae833edbd2b40ad"}, + {file = "contourpy-1.3.2-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:5f5964cdad279256c084b69c3f412b7801e15356b16efa9d78aa974041903da0"}, + {file = "contourpy-1.3.2-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49b65a95d642d4efa8f64ba12558fcb83407e58a2dfba9d796d77b63ccfcaff5"}, + {file = "contourpy-1.3.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:8c5acb8dddb0752bf252e01a3035b21443158910ac16a3b0d20e7fed7d534ce5"}, + {file = "contourpy-1.3.2.tar.gz", hash = "sha256:b6945942715a034c671b7fc54f9588126b0b8bf23db2696e3ca8328f3ff0ab54"}, +] [[package]] name = "contourpy" @@ -346,6 +433,22 @@ files = [ {file = "coverage-7.10.7.tar.gz", hash = "sha256:f4ab143ab113be368a3e9b795f9cd7906c5ef407d6173fe9675a902e1fffc239"}, ] +[[package]] +name = "cv2-enumerate-cameras" +version = "1.3.0" +requires_python = ">=3.6" +summary = "Enumerate / List / Find / Detect / Search index for opencv VideoCapture." +groups = ["default"] +dependencies = [ + "pyobjc-framework-AVFoundation; platform_system == \"Darwin\"", +] +files = [ + {file = "cv2_enumerate_cameras-1.3.0-cp32-abi3-win32.whl", hash = "sha256:bd24be3c2df42b88da59e9f6029b97484fc8468678a2be3900aa717835c40f6a"}, + {file = "cv2_enumerate_cameras-1.3.0-cp32-abi3-win_amd64.whl", hash = "sha256:ef892cc7d92dec3ad0f696fa592a9d5461e593698048f84e877353cffcfc486f"}, + {file = "cv2_enumerate_cameras-1.3.0-py3-none-any.whl", hash = "sha256:7a67d8cc98fd112c3df51ff699c6953884e96088ced2ee05191bdeaaa2e1e847"}, + {file = "cv2_enumerate_cameras-1.3.0.tar.gz", hash = "sha256:795d8005b0e92ca117e1b8c4849c752edb39b7e2a1818b2d7ac4463aa26caac1"}, +] + [[package]] name = "cycler" version = "0.12.1" @@ -425,7 +528,7 @@ files = [ [[package]] name = "furo" -version = "2025.7.19" +version = "2025.9.25" requires_python = ">=3.8" summary = "A clean customisable Sphinx documentation theme." groups = ["all", "docs"] @@ -437,8 +540,8 @@ dependencies = [ "sphinx<9.0,>=6.0", ] files = [ - {file = "furo-2025.7.19-py3-none-any.whl", hash = "sha256:bdea869822dfd2b494ea84c0973937e35d1575af088b6721a29c7f7878adc9e3"}, - {file = "furo-2025.7.19.tar.gz", hash = "sha256:4164b2cafcf4023a59bb3c594e935e2516f6b9d35e9a5ea83d8f6b43808fe91f"}, + {file = "furo-2025.9.25-py3-none-any.whl", hash = "sha256:2937f68e823b8e37b410c972c371bc2b1d88026709534927158e0cb3fac95afe"}, + {file = "furo-2025.9.25.tar.gz", hash = "sha256:3eac05582768fdbbc2bdfa1cdbcdd5d33cfc8b4bd2051729ff4e026a1d7e0a98"}, ] [[package]] @@ -537,6 +640,109 @@ requires_python = ">=3.10" summary = "A fast implementation of the Cassowary constraint solver" groups = ["all", "plot", "tests"] marker = "python_version ~= \"3.10\"" +files = [ + {file = "kiwisolver-1.4.9-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b4b4d74bda2b8ebf4da5bd42af11d02d04428b2c32846e4c2c93219df8a7987b"}, + {file = "kiwisolver-1.4.9-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:fb3b8132019ea572f4611d770991000d7f58127560c4889729248eb5852a102f"}, + {file = "kiwisolver-1.4.9-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:84fd60810829c27ae375114cd379da1fa65e6918e1da405f356a775d49a62bcf"}, + {file = "kiwisolver-1.4.9-cp310-cp310-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:b78efa4c6e804ecdf727e580dbb9cba85624d2e1c6b5cb059c66290063bd99a9"}, + {file = "kiwisolver-1.4.9-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4efec7bcf21671db6a3294ff301d2fc861c31faa3c8740d1a94689234d1b415"}, + {file = "kiwisolver-1.4.9-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:90f47e70293fc3688b71271100a1a5453aa9944a81d27ff779c108372cf5567b"}, + {file = "kiwisolver-1.4.9-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8fdca1def57a2e88ef339de1737a1449d6dbf5fab184c54a1fca01d541317154"}, + {file = "kiwisolver-1.4.9-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:9cf554f21be770f5111a1690d42313e140355e687e05cf82cb23d0a721a64a48"}, + {file = "kiwisolver-1.4.9-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:fc1795ac5cd0510207482c3d1d3ed781143383b8cfd36f5c645f3897ce066220"}, + {file = "kiwisolver-1.4.9-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:ccd09f20ccdbbd341b21a67ab50a119b64a403b09288c27481575105283c1586"}, + {file = "kiwisolver-1.4.9-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:540c7c72324d864406a009d72f5d6856f49693db95d1fbb46cf86febef873634"}, + {file = "kiwisolver-1.4.9-cp310-cp310-win_amd64.whl", hash = "sha256:ede8c6d533bc6601a47ad4046080d36b8fc99f81e6f1c17b0ac3c2dc91ac7611"}, + {file = "kiwisolver-1.4.9-cp310-cp310-win_arm64.whl", hash = "sha256:7b4da0d01ac866a57dd61ac258c5607b4cd677f63abaec7b148354d2b2cdd536"}, + {file = "kiwisolver-1.4.9-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:eb14a5da6dc7642b0f3a18f13654847cd8b7a2550e2645a5bda677862b03ba16"}, + {file = "kiwisolver-1.4.9-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:39a219e1c81ae3b103643d2aedb90f1ef22650deb266ff12a19e7773f3e5f089"}, + {file = "kiwisolver-1.4.9-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2405a7d98604b87f3fc28b1716783534b1b4b8510d8142adca34ee0bc3c87543"}, + {file = "kiwisolver-1.4.9-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:dc1ae486f9abcef254b5618dfb4113dd49f94c68e3e027d03cf0143f3f772b61"}, + {file = "kiwisolver-1.4.9-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a1f570ce4d62d718dce3f179ee78dac3b545ac16c0c04bb363b7607a949c0d1"}, + {file = "kiwisolver-1.4.9-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb27e7b78d716c591e88e0a09a2139c6577865d7f2e152488c2cc6257f460872"}, + {file = "kiwisolver-1.4.9-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:15163165efc2f627eb9687ea5f3a28137217d217ac4024893d753f46bce9de26"}, + {file = "kiwisolver-1.4.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bdee92c56a71d2b24c33a7d4c2856bd6419d017e08caa7802d2963870e315028"}, + {file = "kiwisolver-1.4.9-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:412f287c55a6f54b0650bd9b6dce5aceddb95864a1a90c87af16979d37c89771"}, + {file = "kiwisolver-1.4.9-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:2c93f00dcba2eea70af2be5f11a830a742fe6b579a1d4e00f47760ef13be247a"}, + {file = "kiwisolver-1.4.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f117e1a089d9411663a3207ba874f31be9ac8eaa5b533787024dc07aeb74f464"}, + {file = "kiwisolver-1.4.9-cp311-cp311-win_amd64.whl", hash = "sha256:be6a04e6c79819c9a8c2373317d19a96048e5a3f90bec587787e86a1153883c2"}, + {file = "kiwisolver-1.4.9-cp311-cp311-win_arm64.whl", hash = "sha256:0ae37737256ba2de764ddc12aed4956460277f00c4996d51a197e72f62f5eec7"}, + {file = "kiwisolver-1.4.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ac5a486ac389dddcc5bef4f365b6ae3ffff2c433324fb38dd35e3fab7c957999"}, + {file = "kiwisolver-1.4.9-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f2ba92255faa7309d06fe44c3a4a97efe1c8d640c2a79a5ef728b685762a6fd2"}, + {file = "kiwisolver-1.4.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4a2899935e724dd1074cb568ce7ac0dce28b2cd6ab539c8e001a8578eb106d14"}, + {file = "kiwisolver-1.4.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f6008a4919fdbc0b0097089f67a1eb55d950ed7e90ce2cc3e640abadd2757a04"}, + {file = "kiwisolver-1.4.9-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:67bb8b474b4181770f926f7b7d2f8c0248cbcb78b660fdd41a47054b28d2a752"}, + {file = "kiwisolver-1.4.9-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2327a4a30d3ee07d2fbe2e7933e8a37c591663b96ce42a00bc67461a87d7df77"}, + {file = "kiwisolver-1.4.9-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7a08b491ec91b1d5053ac177afe5290adacf1f0f6307d771ccac5de30592d198"}, + {file = "kiwisolver-1.4.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d8fc5c867c22b828001b6a38d2eaeb88160bf5783c6cb4a5e440efc981ce286d"}, + {file = "kiwisolver-1.4.9-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:3b3115b2581ea35bb6d1f24a4c90af37e5d9b49dcff267eeed14c3893c5b86ab"}, + {file = "kiwisolver-1.4.9-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:858e4c22fb075920b96a291928cb7dea5644e94c0ee4fcd5af7e865655e4ccf2"}, + {file = "kiwisolver-1.4.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ed0fecd28cc62c54b262e3736f8bb2512d8dcfdc2bcf08be5f47f96bf405b145"}, + {file = "kiwisolver-1.4.9-cp312-cp312-win_amd64.whl", hash = "sha256:f68208a520c3d86ea51acf688a3e3002615a7f0238002cccc17affecc86a8a54"}, + {file = "kiwisolver-1.4.9-cp312-cp312-win_arm64.whl", hash = "sha256:2c1a4f57df73965f3f14df20b80ee29e6a7930a57d2d9e8491a25f676e197c60"}, + {file = "kiwisolver-1.4.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a5d0432ccf1c7ab14f9949eec60c5d1f924f17c037e9f8b33352fa05799359b8"}, + {file = "kiwisolver-1.4.9-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efb3a45b35622bb6c16dbfab491a8f5a391fe0e9d45ef32f4df85658232ca0e2"}, + {file = "kiwisolver-1.4.9-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1a12cf6398e8a0a001a059747a1cbf24705e18fe413bc22de7b3d15c67cffe3f"}, + {file = "kiwisolver-1.4.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b67e6efbf68e077dd71d1a6b37e43e1a99d0bff1a3d51867d45ee8908b931098"}, + {file = "kiwisolver-1.4.9-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5656aa670507437af0207645273ccdfee4f14bacd7f7c67a4306d0dcaeaf6eed"}, + {file = "kiwisolver-1.4.9-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bfc08add558155345129c7803b3671cf195e6a56e7a12f3dde7c57d9b417f525"}, + {file = "kiwisolver-1.4.9-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:40092754720b174e6ccf9e845d0d8c7d8e12c3d71e7fc35f55f3813e96376f78"}, + {file = "kiwisolver-1.4.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:497d05f29a1300d14e02e6441cf0f5ee81c1ff5a304b0d9fb77423974684e08b"}, + {file = "kiwisolver-1.4.9-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:bdd1a81a1860476eb41ac4bc1e07b3f07259e6d55bbf739b79c8aaedcf512799"}, + {file = "kiwisolver-1.4.9-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:e6b93f13371d341afee3be9f7c5964e3fe61d5fa30f6a30eb49856935dfe4fc3"}, + {file = "kiwisolver-1.4.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d75aa530ccfaa593da12834b86a0724f58bff12706659baa9227c2ccaa06264c"}, + {file = "kiwisolver-1.4.9-cp313-cp313-win_amd64.whl", hash = "sha256:dd0a578400839256df88c16abddf9ba14813ec5f21362e1fe65022e00c883d4d"}, + {file = "kiwisolver-1.4.9-cp313-cp313-win_arm64.whl", hash = "sha256:d4188e73af84ca82468f09cadc5ac4db578109e52acb4518d8154698d3a87ca2"}, + {file = "kiwisolver-1.4.9-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:5a0f2724dfd4e3b3ac5a82436a8e6fd16baa7d507117e4279b660fe8ca38a3a1"}, + {file = "kiwisolver-1.4.9-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:1b11d6a633e4ed84fc0ddafd4ebfd8ea49b3f25082c04ad12b8315c11d504dc1"}, + {file = "kiwisolver-1.4.9-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61874cdb0a36016354853593cffc38e56fc9ca5aa97d2c05d3dcf6922cd55a11"}, + {file = "kiwisolver-1.4.9-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:60c439763a969a6af93b4881db0eed8fadf93ee98e18cbc35bc8da868d0c4f0c"}, + {file = "kiwisolver-1.4.9-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92a2f997387a1b79a75e7803aa7ded2cfbe2823852ccf1ba3bcf613b62ae3197"}, + {file = "kiwisolver-1.4.9-cp313-cp313t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a31d512c812daea6d8b3be3b2bfcbeb091dbb09177706569bcfc6240dcf8b41c"}, + {file = "kiwisolver-1.4.9-cp313-cp313t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:52a15b0f35dad39862d376df10c5230155243a2c1a436e39eb55623ccbd68185"}, + {file = "kiwisolver-1.4.9-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a30fd6fdef1430fd9e1ba7b3398b5ee4e2887783917a687d86ba69985fb08748"}, + {file = "kiwisolver-1.4.9-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cc9617b46837c6468197b5945e196ee9ca43057bb7d9d1ae688101e4e1dddf64"}, + {file = "kiwisolver-1.4.9-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:0ab74e19f6a2b027ea4f845a78827969af45ce790e6cb3e1ebab71bdf9f215ff"}, + {file = "kiwisolver-1.4.9-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dba5ee5d3981160c28d5490f0d1b7ed730c22470ff7f6cc26cfcfaacb9896a07"}, + {file = "kiwisolver-1.4.9-cp313-cp313t-win_arm64.whl", hash = "sha256:0749fd8f4218ad2e851e11cc4dc05c7cbc0cbc4267bdfdb31782e65aace4ee9c"}, + {file = "kiwisolver-1.4.9-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:9928fe1eb816d11ae170885a74d074f57af3a0d65777ca47e9aeb854a1fba386"}, + {file = "kiwisolver-1.4.9-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:d0005b053977e7b43388ddec89fa567f43d4f6d5c2c0affe57de5ebf290dc552"}, + {file = "kiwisolver-1.4.9-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2635d352d67458b66fd0667c14cb1d4145e9560d503219034a18a87e971ce4f3"}, + {file = "kiwisolver-1.4.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:767c23ad1c58c9e827b649a9ab7809fd5fd9db266a9cf02b0e926ddc2c680d58"}, + {file = "kiwisolver-1.4.9-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72d0eb9fba308b8311685c2268cf7d0a0639a6cd027d8128659f72bdd8a024b4"}, + {file = "kiwisolver-1.4.9-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f68e4f3eeca8fb22cc3d731f9715a13b652795ef657a13df1ad0c7dc0e9731df"}, + {file = "kiwisolver-1.4.9-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d84cd4061ae292d8ac367b2c3fa3aad11cb8625a95d135fe93f286f914f3f5a6"}, + {file = "kiwisolver-1.4.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a60ea74330b91bd22a29638940d115df9dc00af5035a9a2a6ad9399ffb4ceca5"}, + {file = "kiwisolver-1.4.9-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:ce6a3a4e106cf35c2d9c4fa17c05ce0b180db622736845d4315519397a77beaf"}, + {file = "kiwisolver-1.4.9-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:77937e5e2a38a7b48eef0585114fe7930346993a88060d0bf886086d2aa49ef5"}, + {file = "kiwisolver-1.4.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:24c175051354f4a28c5d6a31c93906dc653e2bf234e8a4bbfb964892078898ce"}, + {file = "kiwisolver-1.4.9-cp314-cp314-win_amd64.whl", hash = "sha256:0763515d4df10edf6d06a3c19734e2566368980d21ebec439f33f9eb936c07b7"}, + {file = "kiwisolver-1.4.9-cp314-cp314-win_arm64.whl", hash = "sha256:0e4e2bf29574a6a7b7f6cb5fa69293b9f96c928949ac4a53ba3f525dffb87f9c"}, + {file = "kiwisolver-1.4.9-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d976bbb382b202f71c67f77b0ac11244021cfa3f7dfd9e562eefcea2df711548"}, + {file = "kiwisolver-1.4.9-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2489e4e5d7ef9a1c300a5e0196e43d9c739f066ef23270607d45aba368b91f2d"}, + {file = "kiwisolver-1.4.9-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e2ea9f7ab7fbf18fffb1b5434ce7c69a07582f7acc7717720f1d69f3e806f90c"}, + {file = "kiwisolver-1.4.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b34e51affded8faee0dfdb705416153819d8ea9250bbbf7ea1b249bdeb5f1122"}, + {file = "kiwisolver-1.4.9-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8aacd3d4b33b772542b2e01beb50187536967b514b00003bdda7589722d2a64"}, + {file = "kiwisolver-1.4.9-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7cf974dd4e35fa315563ac99d6287a1024e4dc2077b8a7d7cd3d2fb65d283134"}, + {file = "kiwisolver-1.4.9-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:85bd218b5ecfbee8c8a82e121802dcb519a86044c9c3b2e4aef02fa05c6da370"}, + {file = "kiwisolver-1.4.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0856e241c2d3df4efef7c04a1e46b1936b6120c9bcf36dd216e3acd84bc4fb21"}, + {file = "kiwisolver-1.4.9-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:9af39d6551f97d31a4deebeac6f45b156f9755ddc59c07b402c148f5dbb6482a"}, + {file = "kiwisolver-1.4.9-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:bb4ae2b57fc1d8cbd1cf7b1d9913803681ffa903e7488012be5b76dedf49297f"}, + {file = "kiwisolver-1.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:aedff62918805fb62d43a4aa2ecd4482c380dc76cd31bd7c8878588a61bd0369"}, + {file = "kiwisolver-1.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:1fa333e8b2ce4d9660f2cda9c0e1b6bafcfb2457a9d259faa82289e73ec24891"}, + {file = "kiwisolver-1.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:4a48a2ce79d65d363597ef7b567ce3d14d68783d2b2263d98db3d9477805ba32"}, + {file = "kiwisolver-1.4.9-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:4d1d9e582ad4d63062d34077a9a1e9f3c34088a2ec5135b1f7190c07cf366527"}, + {file = "kiwisolver-1.4.9-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:deed0c7258ceb4c44ad5ec7d9918f9f14fd05b2be86378d86cf50e63d1e7b771"}, + {file = "kiwisolver-1.4.9-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0a590506f303f512dff6b7f75fd2fd18e16943efee932008fe7140e5fa91d80e"}, + {file = "kiwisolver-1.4.9-pp310-pypy310_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e09c2279a4d01f099f52d5c4b3d9e208e91edcbd1a175c9662a8b16e000fece9"}, + {file = "kiwisolver-1.4.9-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:c9e7cdf45d594ee04d5be1b24dd9d49f3d1590959b2271fb30b5ca2b262c00fb"}, + {file = "kiwisolver-1.4.9-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:720e05574713db64c356e86732c0f3c5252818d05f9df320f0ad8380641acea5"}, + {file = "kiwisolver-1.4.9-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:17680d737d5335b552994a2008fab4c851bcd7de33094a82067ef3a576ff02fa"}, + {file = "kiwisolver-1.4.9-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:85b5352f94e490c028926ea567fc569c52ec79ce131dadb968d3853e809518c2"}, + {file = "kiwisolver-1.4.9-pp311-pypy311_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:464415881e4801295659462c49461a24fb107c140de781d55518c4b80cb6790f"}, + {file = "kiwisolver-1.4.9-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:fb940820c63a9590d31d88b815e7a3aa5915cad3ce735ab45f0c730b39547de1"}, + {file = "kiwisolver-1.4.9.tar.gz", hash = "sha256:c3b22c26c6fd6811b0ae8363b95ca8ce4ea3c202d3d0975b2914310ceb1bcc4d"}, +] [[package]] name = "kiwisolver" @@ -607,7 +813,7 @@ files = [ [[package]] name = "matplotlib" -version = "3.10.6" +version = "3.10.7" requires_python = ">=3.10" summary = "Python plotting package" groups = ["all", "plot", "tests"] @@ -620,9 +826,66 @@ dependencies = [ "numpy>=1.23", "packaging>=20.0", "pillow>=8", - "pyparsing>=2.3.1", + "pyparsing>=3", "python-dateutil>=2.7", ] +files = [ + {file = "matplotlib-3.10.7-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:7ac81eee3b7c266dd92cee1cd658407b16c57eed08c7421fa354ed68234de380"}, + {file = "matplotlib-3.10.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:667ecd5d8d37813a845053d8f5bf110b534c3c9f30e69ebd25d4701385935a6d"}, + {file = "matplotlib-3.10.7-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc1c51b846aca49a5a8b44fbba6a92d583a35c64590ad9e1e950dc88940a4297"}, + {file = "matplotlib-3.10.7-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a11c2e9e72e7de09b7b72e62f3df23317c888299c875e2b778abf1eda8c0a42"}, + {file = "matplotlib-3.10.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f19410b486fdd139885ace124e57f938c1e6a3210ea13dd29cab58f5d4bc12c7"}, + {file = "matplotlib-3.10.7-cp310-cp310-win_amd64.whl", hash = "sha256:b498e9e4022f93de2d5a37615200ca01297ceebbb56fe4c833f46862a490f9e3"}, + {file = "matplotlib-3.10.7-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:53b492410a6cd66c7a471de6c924f6ede976e963c0f3097a3b7abfadddc67d0a"}, + {file = "matplotlib-3.10.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d9749313deb729f08207718d29c86246beb2ea3fdba753595b55901dee5d2fd6"}, + {file = "matplotlib-3.10.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2222c7ba2cbde7fe63032769f6eb7e83ab3227f47d997a8453377709b7fe3a5a"}, + {file = "matplotlib-3.10.7-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e91f61a064c92c307c5a9dc8c05dc9f8a68f0a3be199d9a002a0622e13f874a1"}, + {file = "matplotlib-3.10.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6f1851eab59ca082c95df5a500106bad73672645625e04538b3ad0f69471ffcc"}, + {file = "matplotlib-3.10.7-cp311-cp311-win_amd64.whl", hash = "sha256:6516ce375109c60ceec579e699524e9d504cd7578506f01150f7a6bc174a775e"}, + {file = "matplotlib-3.10.7-cp311-cp311-win_arm64.whl", hash = "sha256:b172db79759f5f9bc13ef1c3ef8b9ee7b37b0247f987fbbbdaa15e4f87fd46a9"}, + {file = "matplotlib-3.10.7-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7a0edb7209e21840e8361e91ea84ea676658aa93edd5f8762793dec77a4a6748"}, + {file = "matplotlib-3.10.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c380371d3c23e0eadf8ebff114445b9f970aff2010198d498d4ab4c3b41eea4f"}, + {file = "matplotlib-3.10.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d5f256d49fea31f40f166a5e3131235a5d2f4b7f44520b1cf0baf1ce568ccff0"}, + {file = "matplotlib-3.10.7-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:11ae579ac83cdf3fb72573bb89f70e0534de05266728740d478f0f818983c695"}, + {file = "matplotlib-3.10.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4c14b6acd16cddc3569a2d515cfdd81c7a68ac5639b76548cfc1a9e48b20eb65"}, + {file = "matplotlib-3.10.7-cp312-cp312-win_amd64.whl", hash = "sha256:0d8c32b7ea6fb80b1aeff5a2ceb3fb9778e2759e899d9beff75584714afcc5ee"}, + {file = "matplotlib-3.10.7-cp312-cp312-win_arm64.whl", hash = "sha256:5f3f6d315dcc176ba7ca6e74c7768fb7e4cf566c49cb143f6bc257b62e634ed8"}, + {file = "matplotlib-3.10.7-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1d9d3713a237970569156cfb4de7533b7c4eacdd61789726f444f96a0d28f57f"}, + {file = "matplotlib-3.10.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:37a1fea41153dd6ee061d21ab69c9cf2cf543160b1b85d89cd3d2e2a7902ca4c"}, + {file = "matplotlib-3.10.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b3c4ea4948d93c9c29dc01c0c23eef66f2101bf75158c291b88de6525c55c3d1"}, + {file = "matplotlib-3.10.7-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22df30ffaa89f6643206cf13877191c63a50e8f800b038bc39bee9d2d4957632"}, + {file = "matplotlib-3.10.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b69676845a0a66f9da30e87f48be36734d6748024b525ec4710be40194282c84"}, + {file = "matplotlib-3.10.7-cp313-cp313-win_amd64.whl", hash = "sha256:744991e0cc863dd669c8dc9136ca4e6e0082be2070b9d793cbd64bec872a6815"}, + {file = "matplotlib-3.10.7-cp313-cp313-win_arm64.whl", hash = "sha256:fba2974df0bf8ce3c995fa84b79cde38326e0f7b5409e7a3a481c1141340bcf7"}, + {file = "matplotlib-3.10.7-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:932c55d1fa7af4423422cb6a492a31cbcbdbe68fd1a9a3f545aa5e7a143b5355"}, + {file = "matplotlib-3.10.7-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5e38c2d581d62ee729a6e144c47a71b3f42fb4187508dbbf4fe71d5612c3433b"}, + {file = "matplotlib-3.10.7-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:786656bb13c237bbcebcd402f65f44dd61ead60ee3deb045af429d889c8dbc67"}, + {file = "matplotlib-3.10.7-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:09d7945a70ea43bf9248f4b6582734c2fe726723204a76eca233f24cffc7ef67"}, + {file = "matplotlib-3.10.7-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d0b181e9fa8daf1d9f2d4c547527b167cb8838fc587deabca7b5c01f97199e84"}, + {file = "matplotlib-3.10.7-cp313-cp313t-win_amd64.whl", hash = "sha256:31963603041634ce1a96053047b40961f7a29eb8f9a62e80cc2c0427aa1d22a2"}, + {file = "matplotlib-3.10.7-cp313-cp313t-win_arm64.whl", hash = "sha256:aebed7b50aa6ac698c90f60f854b47e48cd2252b30510e7a1feddaf5a3f72cbf"}, + {file = "matplotlib-3.10.7-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:d883460c43e8c6b173fef244a2341f7f7c0e9725c7fe68306e8e44ed9c8fb100"}, + {file = "matplotlib-3.10.7-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:07124afcf7a6504eafcb8ce94091c5898bbdd351519a1beb5c45f7a38c67e77f"}, + {file = "matplotlib-3.10.7-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c17398b709a6cce3d9fdb1595c33e356d91c098cd9486cb2cc21ea2ea418e715"}, + {file = "matplotlib-3.10.7-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7146d64f561498764561e9cd0ed64fcf582e570fc519e6f521e2d0cfd43365e1"}, + {file = "matplotlib-3.10.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:90ad854c0a435da3104c01e2c6f0028d7e719b690998a2333d7218db80950722"}, + {file = "matplotlib-3.10.7-cp314-cp314-win_amd64.whl", hash = "sha256:4645fc5d9d20ffa3a39361fcdbcec731382763b623b72627806bf251b6388866"}, + {file = "matplotlib-3.10.7-cp314-cp314-win_arm64.whl", hash = "sha256:9257be2f2a03415f9105c486d304a321168e61ad450f6153d77c69504ad764bb"}, + {file = "matplotlib-3.10.7-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1e4bbad66c177a8fdfa53972e5ef8be72a5f27e6a607cec0d8579abd0f3102b1"}, + {file = "matplotlib-3.10.7-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d8eb7194b084b12feb19142262165832fc6ee879b945491d1c3d4660748020c4"}, + {file = "matplotlib-3.10.7-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b4d41379b05528091f00e1728004f9a8d7191260f3862178b88e8fd770206318"}, + {file = "matplotlib-3.10.7-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4a74f79fafb2e177f240579bc83f0b60f82cc47d2f1d260f422a0627207008ca"}, + {file = "matplotlib-3.10.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:702590829c30aada1e8cef0568ddbffa77ca747b4d6e36c6d173f66e301f89cc"}, + {file = "matplotlib-3.10.7-cp314-cp314t-win_amd64.whl", hash = "sha256:f79d5de970fc90cd5591f60053aecfce1fcd736e0303d9f0bf86be649fa68fb8"}, + {file = "matplotlib-3.10.7-cp314-cp314t-win_arm64.whl", hash = "sha256:cb783436e47fcf82064baca52ce748af71725d0352e1d31564cbe9c95df92b9c"}, + {file = "matplotlib-3.10.7-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:5c09cf8f2793f81368f49f118b6f9f937456362bee282eac575cca7f84cda537"}, + {file = "matplotlib-3.10.7-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:de66744b2bb88d5cd27e80dfc2ec9f0517d0a46d204ff98fe9e5f2864eb67657"}, + {file = "matplotlib-3.10.7-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:53cc80662dd197ece414dd5b66e07370201515a3eaf52e7c518c68c16814773b"}, + {file = "matplotlib-3.10.7-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:15112bcbaef211bd663fa935ec33313b948e214454d949b723998a43357b17b0"}, + {file = "matplotlib-3.10.7-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d2a959c640cdeecdd2ec3136e8ea0441da59bcaf58d67e9c590740addba2cb68"}, + {file = "matplotlib-3.10.7-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3886e47f64611046bc1db523a09dd0a0a6bed6081e6f90e13806dd1d1d1b5e91"}, + {file = "matplotlib-3.10.7.tar.gz", hash = "sha256:a06ba7e2a2ef9131c79c49e63dad355d2d878413a0376c1727c8b9335ff731c7"}, +] [[package]] name = "matplotlib" @@ -667,6 +930,10 @@ marker = "python_version ~= \"3.10\"" dependencies = [ "markdown-it-py<5.0.0,>=2.0.0", ] +files = [ + {file = "mdit_py_plugins-0.5.0-py3-none-any.whl", hash = "sha256:07a08422fc1936a5d26d146759e9155ea466e842f5ab2f7d2266dd084c8dab1f"}, + {file = "mdit_py_plugins-0.5.0.tar.gz", hash = "sha256:f4918cb50119f50446560513a8e311d574ff6aaed72606ddae6d35716fe809c6"}, +] [[package]] name = "mdit-py-plugins" @@ -720,6 +987,10 @@ dependencies = [ "pyyaml", "sphinx<9,>=7", ] +files = [ + {file = "myst_parser-4.0.1-py3-none-any.whl", hash = "sha256:9134e88959ec3b5780aedf8a99680ea242869d012e8821db3126d427edc9c95d"}, + {file = "myst_parser-4.0.1.tar.gz", hash = "sha256:5cfea715e4f3574138aecbf7d54132296bfd72bb614d31168f48c477a830a7c4"}, +] [[package]] name = "myst-parser" @@ -759,6 +1030,63 @@ requires_python = ">=3.10" summary = "Fundamental package for array computing in Python" groups = ["default", "all", "plot", "tests"] marker = "python_version ~= \"3.10\"" +files = [ + {file = "numpy-2.2.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b412caa66f72040e6d268491a59f2c43bf03eb6c96dd8f0307829feb7fa2b6fb"}, + {file = "numpy-2.2.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8e41fd67c52b86603a91c1a505ebaef50b3314de0213461c7a6e99c9a3beff90"}, + {file = "numpy-2.2.6-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:37e990a01ae6ec7fe7fa1c26c55ecb672dd98b19c3d0e1d1f326fa13cb38d163"}, + {file = "numpy-2.2.6-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:5a6429d4be8ca66d889b7cf70f536a397dc45ba6faeb5f8c5427935d9592e9cf"}, + {file = "numpy-2.2.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:efd28d4e9cd7d7a8d39074a4d44c63eda73401580c5c76acda2ce969e0a38e83"}, + {file = "numpy-2.2.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc7b73d02efb0e18c000e9ad8b83480dfcd5dfd11065997ed4c6747470ae8915"}, + {file = "numpy-2.2.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:74d4531beb257d2c3f4b261bfb0fc09e0f9ebb8842d82a7b4209415896adc680"}, + {file = "numpy-2.2.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8fc377d995680230e83241d8a96def29f204b5782f371c532579b4f20607a289"}, + {file = "numpy-2.2.6-cp310-cp310-win32.whl", hash = "sha256:b093dd74e50a8cba3e873868d9e93a85b78e0daf2e98c6797566ad8044e8363d"}, + {file = "numpy-2.2.6-cp310-cp310-win_amd64.whl", hash = "sha256:f0fd6321b839904e15c46e0d257fdd101dd7f530fe03fd6359c1ea63738703f3"}, + {file = "numpy-2.2.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f9f1adb22318e121c5c69a09142811a201ef17ab257a1e66ca3025065b7f53ae"}, + {file = "numpy-2.2.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c820a93b0255bc360f53eca31a0e676fd1101f673dda8da93454a12e23fc5f7a"}, + {file = "numpy-2.2.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:3d70692235e759f260c3d837193090014aebdf026dfd167834bcba43e30c2a42"}, + {file = "numpy-2.2.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:481b49095335f8eed42e39e8041327c05b0f6f4780488f61286ed3c01368d491"}, + {file = "numpy-2.2.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b64d8d4d17135e00c8e346e0a738deb17e754230d7e0810ac5012750bbd85a5a"}, + {file = "numpy-2.2.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba10f8411898fc418a521833e014a77d3ca01c15b0c6cdcce6a0d2897e6dbbdf"}, + {file = "numpy-2.2.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bd48227a919f1bafbdda0583705e547892342c26fb127219d60a5c36882609d1"}, + {file = "numpy-2.2.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9551a499bf125c1d4f9e250377c1ee2eddd02e01eac6644c080162c0c51778ab"}, + {file = "numpy-2.2.6-cp311-cp311-win32.whl", hash = "sha256:0678000bb9ac1475cd454c6b8c799206af8107e310843532b04d49649c717a47"}, + {file = "numpy-2.2.6-cp311-cp311-win_amd64.whl", hash = "sha256:e8213002e427c69c45a52bbd94163084025f533a55a59d6f9c5b820774ef3303"}, + {file = "numpy-2.2.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:41c5a21f4a04fa86436124d388f6ed60a9343a6f767fced1a8a71c3fbca038ff"}, + {file = "numpy-2.2.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:de749064336d37e340f640b05f24e9e3dd678c57318c7289d222a8a2f543e90c"}, + {file = "numpy-2.2.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:894b3a42502226a1cac872f840030665f33326fc3dac8e57c607905773cdcde3"}, + {file = "numpy-2.2.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:71594f7c51a18e728451bb50cc60a3ce4e6538822731b2933209a1f3614e9282"}, + {file = "numpy-2.2.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2618db89be1b4e05f7a1a847a9c1c0abd63e63a1607d892dd54668dd92faf87"}, + {file = "numpy-2.2.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd83c01228a688733f1ded5201c678f0c53ecc1006ffbc404db9f7a899ac6249"}, + {file = "numpy-2.2.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:37c0ca431f82cd5fa716eca9506aefcabc247fb27ba69c5062a6d3ade8cf8f49"}, + {file = "numpy-2.2.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fe27749d33bb772c80dcd84ae7e8df2adc920ae8297400dabec45f0dedb3f6de"}, + {file = "numpy-2.2.6-cp312-cp312-win32.whl", hash = "sha256:4eeaae00d789f66c7a25ac5f34b71a7035bb474e679f410e5e1a94deb24cf2d4"}, + {file = "numpy-2.2.6-cp312-cp312-win_amd64.whl", hash = "sha256:c1f9540be57940698ed329904db803cf7a402f3fc200bfe599334c9bd84a40b2"}, + {file = "numpy-2.2.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0811bb762109d9708cca4d0b13c4f67146e3c3b7cf8d34018c722adb2d957c84"}, + {file = "numpy-2.2.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:287cc3162b6f01463ccd86be154f284d0893d2b3ed7292439ea97eafa8170e0b"}, + {file = "numpy-2.2.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:f1372f041402e37e5e633e586f62aa53de2eac8d98cbfb822806ce4bbefcb74d"}, + {file = "numpy-2.2.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:55a4d33fa519660d69614a9fad433be87e5252f4b03850642f88993f7b2ca566"}, + {file = "numpy-2.2.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f92729c95468a2f4f15e9bb94c432a9229d0d50de67304399627a943201baa2f"}, + {file = "numpy-2.2.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bc23a79bfabc5d056d106f9befb8d50c31ced2fbc70eedb8155aec74a45798f"}, + {file = "numpy-2.2.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e3143e4451880bed956e706a3220b4e5cf6172ef05fcc397f6f36a550b1dd868"}, + {file = "numpy-2.2.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b4f13750ce79751586ae2eb824ba7e1e8dba64784086c98cdbbcc6a42112ce0d"}, + {file = "numpy-2.2.6-cp313-cp313-win32.whl", hash = "sha256:5beb72339d9d4fa36522fc63802f469b13cdbe4fdab4a288f0c441b74272ebfd"}, + {file = "numpy-2.2.6-cp313-cp313-win_amd64.whl", hash = "sha256:b0544343a702fa80c95ad5d3d608ea3599dd54d4632df855e4c8d24eb6ecfa1c"}, + {file = "numpy-2.2.6-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0bca768cd85ae743b2affdc762d617eddf3bcf8724435498a1e80132d04879e6"}, + {file = "numpy-2.2.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fc0c5673685c508a142ca65209b4e79ed6740a4ed6b2267dbba90f34b0b3cfda"}, + {file = "numpy-2.2.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:5bd4fc3ac8926b3819797a7c0e2631eb889b4118a9898c84f585a54d475b7e40"}, + {file = "numpy-2.2.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:fee4236c876c4e8369388054d02d0e9bb84821feb1a64dd59e137e6511a551f8"}, + {file = "numpy-2.2.6-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e1dda9c7e08dc141e0247a5b8f49cf05984955246a327d4c48bda16821947b2f"}, + {file = "numpy-2.2.6-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f447e6acb680fd307f40d3da4852208af94afdfab89cf850986c3ca00562f4fa"}, + {file = "numpy-2.2.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:389d771b1623ec92636b0786bc4ae56abafad4a4c513d36a55dce14bd9ce8571"}, + {file = "numpy-2.2.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8e9ace4a37db23421249ed236fdcdd457d671e25146786dfc96835cd951aa7c1"}, + {file = "numpy-2.2.6-cp313-cp313t-win32.whl", hash = "sha256:038613e9fb8c72b0a41f025a7e4c3f0b7a1b5d768ece4796b674c8f3fe13efff"}, + {file = "numpy-2.2.6-cp313-cp313t-win_amd64.whl", hash = "sha256:6031dd6dfecc0cf9f668681a37648373bddd6421fff6c66ec1624eed0180ee06"}, + {file = "numpy-2.2.6-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0b605b275d7bd0c640cad4e5d30fa701a8d59302e127e5f79138ad62762c3e3d"}, + {file = "numpy-2.2.6-pp310-pypy310_pp73-macosx_14_0_x86_64.whl", hash = "sha256:7befc596a7dc9da8a337f79802ee8adb30a552a94f792b9c9d18c840055907db"}, + {file = "numpy-2.2.6-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce47521a4754c8f4593837384bd3424880629f718d87c5d44f8ed763edd63543"}, + {file = "numpy-2.2.6-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d042d24c90c41b54fd506da306759e06e568864df8ec17ccc17e9e884634fd00"}, + {file = "numpy-2.2.6.tar.gz", hash = "sha256:e29554e2bef54a90aa5cc07da6ce955accb83f21ab5de01a62c8478897b264fd"}, +] [[package]] name = "numpy" @@ -818,7 +1146,7 @@ files = [ [[package]] name = "pandas" -version = "2.3.2" +version = "2.3.3" requires_python = ">=3.9" summary = "Powerful data structures for data analysis, time series, and statistics" groups = ["default"] @@ -831,14 +1159,61 @@ dependencies = [ "tzdata>=2022.7", ] files = [ - {file = "pandas-2.3.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:88080a0ff8a55eac9c84e3ff3c7665b3b5476c6fbc484775ca1910ce1c3e0b87"}, - {file = "pandas-2.3.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:d4a558c7620340a0931828d8065688b3cc5b4c8eb674bcaf33d18ff4a6870b4a"}, - {file = "pandas-2.3.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45178cf09d1858a1509dc73ec261bf5b25a625a389b65be2e47b559905f0ab6a"}, - {file = "pandas-2.3.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:77cefe00e1b210f9c76c697fedd8fdb8d3dd86563e9c8adc9fa72b90f5e9e4c2"}, - {file = "pandas-2.3.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:13bd629c653856f00c53dc495191baa59bcafbbf54860a46ecc50d3a88421a96"}, - {file = "pandas-2.3.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:36d627906fd44b5fd63c943264e11e96e923f8de77d6016dc2f667b9ad193438"}, - {file = "pandas-2.3.2-cp39-cp39-win_amd64.whl", hash = "sha256:a9d7ec92d71a420185dec44909c32e9a362248c4ae2238234b76d5be37f208cc"}, - {file = "pandas-2.3.2.tar.gz", hash = "sha256:ab7b58f8f82706890924ccdfb5f48002b83d2b5a3845976a9fb705d36c34dcdb"}, + {file = "pandas-2.3.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:376c6446ae31770764215a6c937f72d917f214b43560603cd60da6408f183b6c"}, + {file = "pandas-2.3.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e19d192383eab2f4ceb30b412b22ea30690c9e618f78870357ae1d682912015a"}, + {file = "pandas-2.3.3-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5caf26f64126b6c7aec964f74266f435afef1c1b13da3b0636c7518a1fa3e2b1"}, + {file = "pandas-2.3.3-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dd7478f1463441ae4ca7308a70e90b33470fa593429f9d4c578dd00d1fa78838"}, + {file = "pandas-2.3.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:4793891684806ae50d1288c9bae9330293ab4e083ccd1c5e383c34549c6e4250"}, + {file = "pandas-2.3.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:28083c648d9a99a5dd035ec125d42439c6c1c525098c58af0fc38dd1a7a1b3d4"}, + {file = "pandas-2.3.3-cp310-cp310-win_amd64.whl", hash = "sha256:503cf027cf9940d2ceaa1a93cfb5f8c8c7e6e90720a2850378f0b3f3b1e06826"}, + {file = "pandas-2.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:602b8615ebcc4a0c1751e71840428ddebeb142ec02c786e8ad6b1ce3c8dec523"}, + {file = "pandas-2.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8fe25fc7b623b0ef6b5009149627e34d2a4657e880948ec3c840e9402e5c1b45"}, + {file = "pandas-2.3.3-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b468d3dad6ff947df92dcb32ede5b7bd41a9b3cceef0a30ed925f6d01fb8fa66"}, + {file = "pandas-2.3.3-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b98560e98cb334799c0b07ca7967ac361a47326e9b4e5a7dfb5ab2b1c9d35a1b"}, + {file = "pandas-2.3.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37b5848ba49824e5c30bedb9c830ab9b7751fd049bc7914533e01c65f79791"}, + {file = "pandas-2.3.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:db4301b2d1f926ae677a751eb2bd0e8c5f5319c9cb3f88b0becbbb0b07b34151"}, + {file = "pandas-2.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:f086f6fe114e19d92014a1966f43a3e62285109afe874f067f5abbdcbb10e59c"}, + {file = "pandas-2.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d21f6d74eb1725c2efaa71a2bfc661a0689579b58e9c0ca58a739ff0b002b53"}, + {file = "pandas-2.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3fd2f887589c7aa868e02632612ba39acb0b8948faf5cc58f0850e165bd46f35"}, + {file = "pandas-2.3.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecaf1e12bdc03c86ad4a7ea848d66c685cb6851d807a26aa245ca3d2017a1908"}, + {file = "pandas-2.3.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b3d11d2fda7eb164ef27ffc14b4fcab16a80e1ce67e9f57e19ec0afaf715ba89"}, + {file = "pandas-2.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a68e15f780eddf2b07d242e17a04aa187a7ee12b40b930bfdd78070556550e98"}, + {file = "pandas-2.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:371a4ab48e950033bcf52b6527eccb564f52dc826c02afd9a1bc0ab731bba084"}, + {file = "pandas-2.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:a16dcec078a01eeef8ee61bf64074b4e524a2a3f4b3be9326420cabe59c4778b"}, + {file = "pandas-2.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:56851a737e3470de7fa88e6131f41281ed440d29a9268dcbf0002da5ac366713"}, + {file = "pandas-2.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdcd9d1167f4885211e401b3036c0c8d9e274eee67ea8d0758a256d60704cfe8"}, + {file = "pandas-2.3.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e32e7cc9af0f1cc15548288a51a3b681cc2a219faa838e995f7dc53dbab1062d"}, + {file = "pandas-2.3.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:318d77e0e42a628c04dc56bcef4b40de67918f7041c2b061af1da41dcff670ac"}, + {file = "pandas-2.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4e0a175408804d566144e170d0476b15d78458795bb18f1304fb94160cabf40c"}, + {file = "pandas-2.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:93c2d9ab0fc11822b5eece72ec9587e172f63cff87c00b062f6e37448ced4493"}, + {file = "pandas-2.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:f8bfc0e12dc78f777f323f55c58649591b2cd0c43534e8355c51d3fede5f4dee"}, + {file = "pandas-2.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:75ea25f9529fdec2d2e93a42c523962261e567d250b0013b16210e1d40d7c2e5"}, + {file = "pandas-2.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:74ecdf1d301e812db96a465a525952f4dde225fdb6d8e5a521d47e1f42041e21"}, + {file = "pandas-2.3.3-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6435cb949cb34ec11cc9860246ccb2fdc9ecd742c12d3304989017d53f039a78"}, + {file = "pandas-2.3.3-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:900f47d8f20860de523a1ac881c4c36d65efcb2eb850e6948140fa781736e110"}, + {file = "pandas-2.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a45c765238e2ed7d7c608fc5bc4a6f88b642f2f01e70c0c23d2224dd21829d86"}, + {file = "pandas-2.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c4fc4c21971a1a9f4bdb4c73978c7f7256caa3e62b323f70d6cb80db583350bc"}, + {file = "pandas-2.3.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:ee15f284898e7b246df8087fc82b87b01686f98ee67d85a17b7ab44143a3a9a0"}, + {file = "pandas-2.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1611aedd912e1ff81ff41c745822980c49ce4a7907537be8692c8dbc31924593"}, + {file = "pandas-2.3.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d2cefc361461662ac48810cb14365a365ce864afe85ef1f447ff5a1e99ea81c"}, + {file = "pandas-2.3.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ee67acbbf05014ea6c763beb097e03cd629961c8a632075eeb34247120abcb4b"}, + {file = "pandas-2.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c46467899aaa4da076d5abc11084634e2d197e9460643dd455ac3db5856b24d6"}, + {file = "pandas-2.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6253c72c6a1d990a410bc7de641d34053364ef8bcd3126f7e7450125887dffe3"}, + {file = "pandas-2.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:1b07204a219b3b7350abaae088f451860223a52cfb8a6c53358e7948735158e5"}, + {file = "pandas-2.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2462b1a365b6109d275250baaae7b760fd25c726aaca0054649286bcfbb3e8ec"}, + {file = "pandas-2.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0242fe9a49aa8b4d78a4fa03acb397a58833ef6199e9aa40a95f027bb3a1b6e7"}, + {file = "pandas-2.3.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a21d830e78df0a515db2b3d2f5570610f5e6bd2e27749770e8bb7b524b89b450"}, + {file = "pandas-2.3.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2e3ebdb170b5ef78f19bfb71b0dc5dc58775032361fa188e814959b74d726dd5"}, + {file = "pandas-2.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d051c0e065b94b7a3cea50eb1ec32e912cd96dba41647eb24104b6c6c14c5788"}, + {file = "pandas-2.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3869faf4bd07b3b66a9f462417d0ca3a9df29a9f6abd5d0d0dbab15dac7abe87"}, + {file = "pandas-2.3.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:c503ba5216814e295f40711470446bc3fd00f0faea8a086cbc688808e26f92a2"}, + {file = "pandas-2.3.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a637c5cdfa04b6d6e2ecedcb81fc52ffb0fd78ce2ebccc9ea964df9f658de8c8"}, + {file = "pandas-2.3.3-cp39-cp39-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:854d00d556406bffe66a4c0802f334c9ad5a96b4f1f868adf036a21b11ef13ff"}, + {file = "pandas-2.3.3-cp39-cp39-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf1f8a81d04ca90e32a0aceb819d34dbd378a98bf923b6398b9a3ec0bf44de29"}, + {file = "pandas-2.3.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:23ebd657a4d38268c7dfbdf089fbc31ea709d82e4923c5ffd4fbd5747133ce73"}, + {file = "pandas-2.3.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:5554c929ccc317d41a5e3d1234f3be588248e61f08a74dd17c9eabb535777dc9"}, + {file = "pandas-2.3.3-cp39-cp39-win_amd64.whl", hash = "sha256:d3e28b3e83862ccf4d85ff19cf8c20b2ae7e503881711ff2d534dc8f761131aa"}, + {file = "pandas-2.3.3.tar.gz", hash = "sha256:e05e1af93b977f7eafa636d043f9f94c7ee3ac81af99c13508215942e64c993b"}, ] [[package]] @@ -873,12 +1248,25 @@ files = [ {file = "pillow-11.3.0.tar.gz", hash = "sha256:3828ee7586cd0b2091b6209e5ad53e20d0649bbe87164a459d0676e035e8f523"}, ] +[[package]] +name = "platformdirs" +version = "4.5.0" +requires_python = ">=3.10" +summary = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." +groups = ["default", "all", "dev"] +marker = "python_version ~= \"3.10\"" +files = [ + {file = "platformdirs-4.5.0-py3-none-any.whl", hash = "sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3"}, + {file = "platformdirs-4.5.0.tar.gz", hash = "sha256:70ddccdd7c99fc5942e9fc25636a8b34d04c24b335100223152c2803e4063312"}, +] + [[package]] name = "platformdirs" version = "4.4.0" requires_python = ">=3.9" summary = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." groups = ["default", "all", "dev"] +marker = "python_version < \"3.10\" and python_version >= \"3.9\"" files = [ {file = "platformdirs-4.4.0-py3-none-any.whl", hash = "sha256:abd01743f24e5287cd7a5db3752faf1a2d65353f38ec26d98e25a6db65958c85"}, {file = "platformdirs-4.4.0.tar.gz", hash = "sha256:ca753cf4d81dc309bc67b0ea38fd15dc97bc30ce419a7f58d13eb3bf14c4febf"}, @@ -895,12 +1283,32 @@ files = [ {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, ] +[[package]] +name = "pre-commit" +version = "4.5.0" +requires_python = ">=3.10" +summary = "A framework for managing and maintaining multi-language pre-commit hooks." +groups = ["all", "dev"] +marker = "python_version ~= \"3.10\"" +dependencies = [ + "cfgv>=2.0.0", + "identify>=1.0.0", + "nodeenv>=0.11.1", + "pyyaml>=5.1", + "virtualenv>=20.10.0", +] +files = [ + {file = "pre_commit-4.5.0-py2.py3-none-any.whl", hash = "sha256:25e2ce09595174d9c97860a95609f9f852c0614ba602de3561e267547f2335e1"}, + {file = "pre_commit-4.5.0.tar.gz", hash = "sha256:dc5a065e932b19fc1d4c653c6939068fe54325af8e741e74e88db4d28a4dd66b"}, +] + [[package]] name = "pre-commit" version = "4.3.0" requires_python = ">=3.9" summary = "A framework for managing and maintaining multi-language pre-commit hooks." groups = ["all", "dev"] +marker = "python_version < \"3.10\" and python_version >= \"3.9\"" dependencies = [ "cfgv>=2.0.0", "identify>=1.0.0", @@ -915,70 +1323,165 @@ files = [ [[package]] name = "pydantic" -version = "2.11.9" +version = "2.12.4" requires_python = ">=3.9" summary = "Data validation using Python type hints" groups = ["default", "all", "docs"] dependencies = [ "annotated-types>=0.6.0", - "pydantic-core==2.33.2", - "typing-extensions>=4.12.2", - "typing-inspection>=0.4.0", + "pydantic-core==2.41.5", + "typing-extensions>=4.14.1", + "typing-inspection>=0.4.2", ] files = [ - {file = "pydantic-2.11.9-py3-none-any.whl", hash = "sha256:c42dd626f5cfc1c6950ce6205ea58c93efa406da65f479dcb4029d5934857da2"}, - {file = "pydantic-2.11.9.tar.gz", hash = "sha256:6b8ffda597a14812a7975c90b82a8a2e777d9257aba3453f973acd3c032a18e2"}, + {file = "pydantic-2.12.4-py3-none-any.whl", hash = "sha256:92d3d202a745d46f9be6df459ac5a064fdaa3c1c4cd8adcfa332ccf3c05f871e"}, + {file = "pydantic-2.12.4.tar.gz", hash = "sha256:0f8cb9555000a4b5b617f66bfd2566264c4984b27589d3b845685983e8ea85ac"}, ] [[package]] name = "pydantic-core" -version = "2.33.2" +version = "2.41.5" requires_python = ">=3.9" summary = "Core functionality for Pydantic validation and serialization" groups = ["default", "all", "docs"] dependencies = [ - "typing-extensions!=4.7.0,>=4.6.0", -] -files = [ - {file = "pydantic_core-2.33.2-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:a2b911a5b90e0374d03813674bf0a5fbbb7741570dcd4b4e85a2e48d17def29d"}, - {file = "pydantic_core-2.33.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:6fa6dfc3e4d1f734a34710f391ae822e0a8eb8559a85c6979e14e65ee6ba2954"}, - {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c54c939ee22dc8e2d545da79fc5381f1c020d6d3141d3bd747eab59164dc89fb"}, - {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:53a57d2ed685940a504248187d5685e49eb5eef0f696853647bf37c418c538f7"}, - {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:09fb9dd6571aacd023fe6aaca316bd01cf60ab27240d7eb39ebd66a3a15293b4"}, - {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0e6116757f7959a712db11f3e9c0a99ade00a5bbedae83cb801985aa154f071b"}, - {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d55ab81c57b8ff8548c3e4947f119551253f4e3787a7bbc0b6b3ca47498a9d3"}, - {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c20c462aa4434b33a2661701b861604913f912254e441ab8d78d30485736115a"}, - {file = "pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:44857c3227d3fb5e753d5fe4a3420d6376fa594b07b621e220cd93703fe21782"}, - {file = "pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:eb9b459ca4df0e5c87deb59d37377461a538852765293f9e6ee834f0435a93b9"}, - {file = "pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:9fcd347d2cc5c23b06de6d3b7b8275be558a0c90549495c699e379a80bf8379e"}, - {file = "pydantic_core-2.33.2-cp39-cp39-win32.whl", hash = "sha256:83aa99b1285bc8f038941ddf598501a86f1536789740991d7d8756e34f1e74d9"}, - {file = "pydantic_core-2.33.2-cp39-cp39-win_amd64.whl", hash = "sha256:f481959862f57f29601ccced557cc2e817bce7533ab8e01a797a48b49c9692b3"}, - {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:87acbfcf8e90ca885206e98359d7dca4bcbb35abdc0ff66672a293e1d7a19101"}, - {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:7f92c15cd1e97d4b12acd1cc9004fa092578acfa57b67ad5e43a197175d01a64"}, - {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d3f26877a748dc4251cfcfda9dfb5f13fcb034f5308388066bcfe9031b63ae7d"}, - {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dac89aea9af8cd672fa7b510e7b8c33b0bba9a43186680550ccf23020f32d535"}, - {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:970919794d126ba8645f3837ab6046fb4e72bbc057b3709144066204c19a455d"}, - {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:3eb3fe62804e8f859c49ed20a8451342de53ed764150cb14ca71357c765dc2a6"}, - {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:3abcd9392a36025e3bd55f9bd38d908bd17962cc49bc6da8e7e96285336e2bca"}, - {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:3a1c81334778f9e3af2f8aeb7a960736e5cab1dfebfb26aabca09afd2906c039"}, - {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:2807668ba86cb38c6817ad9bc66215ab8584d1d304030ce4f0887336f28a5e27"}, - {file = "pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc"}, + "typing-extensions>=4.14.1", +] +files = [ + {file = "pydantic_core-2.41.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146"}, + {file = "pydantic_core-2.41.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2"}, + {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5921a4d3ca3aee735d9fd163808f5e8dd6c6972101e4adbda9a4667908849b97"}, + {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e25c479382d26a2a41b7ebea1043564a937db462816ea07afa8a44c0866d52f9"}, + {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f547144f2966e1e16ae626d8ce72b4cfa0caedc7fa28052001c94fb2fcaa1c52"}, + {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f52298fbd394f9ed112d56f3d11aabd0d5bd27beb3084cc3d8ad069483b8941"}, + {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:100baa204bb412b74fe285fb0f3a385256dad1d1879f0a5cb1499ed2e83d132a"}, + {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:05a2c8852530ad2812cb7914dc61a1125dc4e06252ee98e5638a12da6cc6fb6c"}, + {file = "pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:29452c56df2ed968d18d7e21f4ab0ac55e71dc59524872f6fc57dcf4a3249ed2"}, + {file = "pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:d5160812ea7a8a2ffbe233d8da666880cad0cbaf5d4de74ae15c313213d62556"}, + {file = "pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:df3959765b553b9440adfd3c795617c352154e497a4eaf3752555cfb5da8fc49"}, + {file = "pydantic_core-2.41.5-cp310-cp310-win32.whl", hash = "sha256:1f8d33a7f4d5a7889e60dc39856d76d09333d8a6ed0f5f1190635cbec70ec4ba"}, + {file = "pydantic_core-2.41.5-cp310-cp310-win_amd64.whl", hash = "sha256:62de39db01b8d593e45871af2af9e497295db8d73b085f6bfd0b18c83c70a8f9"}, + {file = "pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6"}, + {file = "pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b"}, + {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a"}, + {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8"}, + {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e"}, + {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1"}, + {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b"}, + {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b"}, + {file = "pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284"}, + {file = "pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594"}, + {file = "pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e"}, + {file = "pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b"}, + {file = "pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe"}, + {file = "pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f"}, + {file = "pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7"}, + {file = "pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0"}, + {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69"}, + {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75"}, + {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05"}, + {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc"}, + {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c"}, + {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5"}, + {file = "pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c"}, + {file = "pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294"}, + {file = "pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1"}, + {file = "pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d"}, + {file = "pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815"}, + {file = "pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3"}, + {file = "pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9"}, + {file = "pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34"}, + {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0"}, + {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33"}, + {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e"}, + {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2"}, + {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586"}, + {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d"}, + {file = "pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740"}, + {file = "pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e"}, + {file = "pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858"}, + {file = "pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36"}, + {file = "pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11"}, + {file = "pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd"}, + {file = "pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a"}, + {file = "pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14"}, + {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1"}, + {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66"}, + {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869"}, + {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2"}, + {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375"}, + {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553"}, + {file = "pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90"}, + {file = "pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07"}, + {file = "pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb"}, + {file = "pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23"}, + {file = "pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf"}, + {file = "pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008"}, + {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b5819cd790dbf0c5eb9f82c73c16b39a65dd6dd4d1439dcdea7816ec9adddab8"}, + {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5a4e67afbc95fa5c34cf27d9089bca7fcab4e51e57278d710320a70b956d1b9a"}, + {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ece5c59f0ce7d001e017643d8d24da587ea1f74f6993467d85ae8a5ef9d4f42b"}, + {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:16f80f7abe3351f8ea6858914ddc8c77e02578544a0ebc15b4c2e1a0e813b0b2"}, + {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:33cb885e759a705b426baada1fe68cbb0a2e68e34c5d0d0289a364cf01709093"}, + {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:c8d8b4eb992936023be7dee581270af5c6e0697a8559895f527f5b7105ecd36a"}, + {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:242a206cd0318f95cd21bdacff3fcc3aab23e79bba5cac3db5a841c9ef9c6963"}, + {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d3a978c4f57a597908b7e697229d996d77a6d3c94901e9edee593adada95ce1a"}, + {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26"}, + {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808"}, + {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc"}, + {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1"}, + {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84"}, + {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770"}, + {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f"}, + {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51"}, + {file = "pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e"}, ] [[package]] name = "pydantic-settings" -version = "2.10.1" +version = "2.12.0" +requires_python = ">=3.10" +summary = "Settings management using Pydantic" +groups = ["default", "all", "docs"] +marker = "python_version ~= \"3.10\"" +dependencies = [ + "pydantic>=2.7.0", + "python-dotenv>=0.21.0", + "typing-inspection>=0.4.0", +] +files = [ + {file = "pydantic_settings-2.12.0-py3-none-any.whl", hash = "sha256:fddb9fd99a5b18da837b29710391e945b1e30c135477f484084ee513adb93809"}, + {file = "pydantic_settings-2.12.0.tar.gz", hash = "sha256:005538ef951e3c2a68e1c08b292b5f2e71490def8589d4221b95dab00dafcfd0"}, +] + +[[package]] +name = "pydantic-settings" +version = "2.11.0" requires_python = ">=3.9" summary = "Settings management using Pydantic" groups = ["default", "all", "docs"] +marker = "python_version < \"3.10\" and python_version >= \"3.9\"" dependencies = [ "pydantic>=2.7.0", "python-dotenv>=0.21.0", "typing-inspection>=0.4.0", ] files = [ - {file = "pydantic_settings-2.10.1-py3-none-any.whl", hash = "sha256:a60952460b99cf661dc25c29c0ef171721f98bfcb52ef8d9ea4c943d7c8cc796"}, - {file = "pydantic_settings-2.10.1.tar.gz", hash = "sha256:06f0062169818d0f5524420a360d632d5857b83cffd4d42fe29597807a1614ee"}, + {file = "pydantic_settings-2.11.0-py3-none-any.whl", hash = "sha256:fe2cea3413b9530d10f3a5875adffb17ada5c1e1bab0b2885546d7310415207c"}, + {file = "pydantic_settings-2.11.0.tar.gz", hash = "sha256:d0e87a1c7d33593beb7194adb8470fc426e95ba02af83a0f23474a04c9a08180"}, ] [[package]] @@ -992,6 +1495,230 @@ files = [ {file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"}, ] +[[package]] +name = "pyobjc-core" +version = "12.1" +requires_python = ">=3.10" +summary = "Python<->ObjC Interoperability Module" +groups = ["default"] +marker = "platform_system == \"Darwin\" and python_version ~= \"3.10\"" +files = [ + {file = "pyobjc_core-12.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:93418e79c1655f66b4352168f8c85c942707cb1d3ea13a1da3e6f6a143bacda7"}, + {file = "pyobjc_core-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c918ebca280925e7fcb14c5c43ce12dcb9574a33cccb889be7c8c17f3bcce8b6"}, + {file = "pyobjc_core-12.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:818bcc6723561f207e5b5453efe9703f34bc8781d11ce9b8be286bb415eb4962"}, + {file = "pyobjc_core-12.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:01c0cf500596f03e21c23aef9b5f326b9fb1f8f118cf0d8b66749b6cf4cbb37a"}, + {file = "pyobjc_core-12.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:177aaca84bb369a483e4961186704f64b2697708046745f8167e818d968c88fc"}, + {file = "pyobjc_core-12.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:844515f5d86395b979d02152576e7dee9cc679acc0b32dc626ef5bda315eaa43"}, + {file = "pyobjc_core-12.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:453b191df1a4b80e756445b935491b974714456ae2cbae816840bd96f86db882"}, + {file = "pyobjc_core-12.1.tar.gz", hash = "sha256:2bb3903f5387f72422145e1466b3ac3f7f0ef2e9960afa9bcd8961c5cbf8bd21"}, +] + +[[package]] +name = "pyobjc-core" +version = "11.1" +requires_python = ">=3.8" +summary = "Python<->ObjC Interoperability Module" +groups = ["default"] +marker = "python_version < \"3.10\" and python_version >= \"3.9\" and platform_system == \"Darwin\"" +files = [ + {file = "pyobjc_core-11.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:4a99e6558b48b8e47c092051e7b3be05df1c8d0617b62f6fa6a316c01902d157"}, + {file = "pyobjc_core-11.1.tar.gz", hash = "sha256:b63d4d90c5df7e762f34739b39cc55bc63dbcf9fb2fb3f2671e528488c7a87fe"}, +] + +[[package]] +name = "pyobjc-framework-avfoundation" +version = "12.1" +requires_python = ">=3.10" +summary = "Wrappers for the framework AVFoundation on macOS" +groups = ["default"] +marker = "platform_system == \"Darwin\" and python_version ~= \"3.10\"" +dependencies = [ + "pyobjc-core>=12.1", + "pyobjc-framework-Cocoa>=12.1", + "pyobjc-framework-CoreAudio>=12.1", + "pyobjc-framework-CoreMedia>=12.1", + "pyobjc-framework-Quartz>=12.1", +] +files = [ + {file = "pyobjc_framework_avfoundation-12.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:370d5f1149c1041028cb1f5fb61b9f56655fe53bbffafc79393b0824a474bef0"}, + {file = "pyobjc_framework_avfoundation-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:82cc2c2d9ab6cc04feeb4700ff251d00f1fcafff573c63d4e87168ff80adb926"}, + {file = "pyobjc_framework_avfoundation-12.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bf634f89265b4d93126153200d885b6de4859ed6b3bc65e69ff75540bc398406"}, + {file = "pyobjc_framework_avfoundation-12.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f8ac7f7e0884ac8f12009cdb9d4fefc2f269294ab2ccfd84520a560859b69cec"}, + {file = "pyobjc_framework_avfoundation-12.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:51aba2c6816badfb1fb5a2de1b68b33a23f065bf9e3b99d46ede0c8c774ac7a4"}, + {file = "pyobjc_framework_avfoundation-12.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:9a3ffd1ae90bd72dbcf2875aa9254369e805b904140362a7338ebf1af54201a6"}, + {file = "pyobjc_framework_avfoundation-12.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:394c99876b9a38db4851ddf8146db363556895c12e9c711ccd3c3f907ac8e273"}, + {file = "pyobjc_framework_avfoundation-12.1.tar.gz", hash = "sha256:eda0bb60be380f9ba2344600c4231dd58a3efafa99fdc65d3673ecfbb83f6fcb"}, +] + +[[package]] +name = "pyobjc-framework-avfoundation" +version = "11.1" +requires_python = ">=3.9" +summary = "Wrappers for the framework AVFoundation on macOS" +groups = ["default"] +marker = "python_version < \"3.10\" and python_version >= \"3.9\" and platform_system == \"Darwin\"" +dependencies = [ + "pyobjc-core>=11.1", + "pyobjc-framework-Cocoa>=11.1", + "pyobjc-framework-CoreAudio>=11.1", + "pyobjc-framework-CoreMedia>=11.1", + "pyobjc-framework-Quartz>=11.1", +] +files = [ + {file = "pyobjc_framework_avfoundation-11.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:54e1a65ad908d2dc69ea9d644f02893e397f750194c457479caa9487acf08fbb"}, + {file = "pyobjc_framework_avfoundation-11.1.tar.gz", hash = "sha256:6663056cc6ca49af8de6d36a7fff498f51e1a9a7f1bde7afba718a8ceaaa7377"}, +] + +[[package]] +name = "pyobjc-framework-cocoa" +version = "12.1" +requires_python = ">=3.10" +summary = "Wrappers for the Cocoa frameworks on macOS" +groups = ["default"] +marker = "platform_system == \"Darwin\" and python_version ~= \"3.10\"" +dependencies = [ + "pyobjc-core>=12.1", +] +files = [ + {file = "pyobjc_framework_cocoa-12.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:9b880d3bdcd102809d704b6d8e14e31611443aa892d9f60e8491e457182fdd48"}, + {file = "pyobjc_framework_cocoa-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:f52228bcf38da64b77328787967d464e28b981492b33a7675585141e1b0a01e6"}, + {file = "pyobjc_framework_cocoa-12.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:547c182837214b7ec4796dac5aee3aa25abc665757b75d7f44f83c994bcb0858"}, + {file = "pyobjc_framework_cocoa-12.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5a3dcd491cacc2f5a197142b3c556d8aafa3963011110102a093349017705118"}, + {file = "pyobjc_framework_cocoa-12.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:914b74328c22d8ca261d78c23ef2befc29776e0b85555973927b338c5734ca44"}, + {file = "pyobjc_framework_cocoa-12.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:03342a60fc0015bcdf9b93ac0b4f457d3938e9ef761b28df9564c91a14f0129a"}, + {file = "pyobjc_framework_cocoa-12.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:6ba1dc1bfa4da42d04e93d2363491275fb2e2be5c20790e561c8a9e09b8cf2cc"}, + {file = "pyobjc_framework_cocoa-12.1.tar.gz", hash = "sha256:5556c87db95711b985d5efdaaf01c917ddd41d148b1e52a0c66b1a2e2c5c1640"}, +] + +[[package]] +name = "pyobjc-framework-cocoa" +version = "11.1" +requires_python = ">=3.9" +summary = "Wrappers for the Cocoa frameworks on macOS" +groups = ["default"] +marker = "python_version < \"3.10\" and python_version >= \"3.9\" and platform_system == \"Darwin\"" +dependencies = [ + "pyobjc-core>=11.1", +] +files = [ + {file = "pyobjc_framework_cocoa-11.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:bbee71eeb93b1b31ffbac8560b59a0524a8a4b90846a260d2c4f2188f3d4c721"}, + {file = "pyobjc_framework_cocoa-11.1.tar.gz", hash = "sha256:87df76b9b73e7ca699a828ff112564b59251bb9bbe72e610e670a4dc9940d038"}, +] + +[[package]] +name = "pyobjc-framework-coreaudio" +version = "12.1" +requires_python = ">=3.10" +summary = "Wrappers for the framework CoreAudio on macOS" +groups = ["default"] +marker = "platform_system == \"Darwin\" and python_version ~= \"3.10\"" +dependencies = [ + "pyobjc-core>=12.1", + "pyobjc-framework-Cocoa>=12.1", +] +files = [ + {file = "pyobjc_framework_coreaudio-12.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d676c85bb9dc51217d94adc3b5b60e7e5b59a81167446f06821b2687d92641d3"}, + {file = "pyobjc_framework_coreaudio-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:a452de6b509fa4a20160c0410b72330ac871696cd80237883955a5b3a4de8f2a"}, + {file = "pyobjc_framework_coreaudio-12.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a5ad6309779663f846ab36fe6c49647e470b7e08473c3e48b4f004017bdb68a4"}, + {file = "pyobjc_framework_coreaudio-12.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:3d8ef424850c8ae2146f963afaec6c4f5bf0c2e412871e68fb6ecfb209b8376f"}, + {file = "pyobjc_framework_coreaudio-12.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:6552624df39dbc68ff9328f244ba56f59234ecbde8455db1e617a71bc4f3dd3a"}, + {file = "pyobjc_framework_coreaudio-12.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:78ea67483a5deb21625c189328152008d278fe1da4304da9fcc1babd12627038"}, + {file = "pyobjc_framework_coreaudio-12.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:8d81b0d0296ab4571a4ff302e5cdb52386e486eb8749e99b95b9141438558ca2"}, + {file = "pyobjc_framework_coreaudio-12.1.tar.gz", hash = "sha256:a9e72925fcc1795430496ce0bffd4ddaa92c22460a10308a7283ade830089fe1"}, +] + +[[package]] +name = "pyobjc-framework-coreaudio" +version = "11.1" +requires_python = ">=3.9" +summary = "Wrappers for the framework CoreAudio on macOS" +groups = ["default"] +marker = "python_version < \"3.10\" and python_version >= \"3.9\" and platform_system == \"Darwin\"" +dependencies = [ + "pyobjc-core>=11.1", + "pyobjc-framework-Cocoa>=11.1", +] +files = [ + {file = "pyobjc_framework_coreaudio-11.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:65b9275cb00dfa75560cb20adcdc52025b875d8ed98b94c8c937f3526cfb0386"}, + {file = "pyobjc_framework_coreaudio-11.1.tar.gz", hash = "sha256:b7b89540ae7efc6c1e3208ac838ef2acfc4d2c506dd629d91f6b3b3120e55c1b"}, +] + +[[package]] +name = "pyobjc-framework-coremedia" +version = "12.1" +requires_python = ">=3.10" +summary = "Wrappers for the framework CoreMedia on macOS" +groups = ["default"] +marker = "platform_system == \"Darwin\" and python_version ~= \"3.10\"" +dependencies = [ + "pyobjc-core>=12.1", + "pyobjc-framework-Cocoa>=12.1", +] +files = [ + {file = "pyobjc_framework_coremedia-12.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:4fce8570db3eaa7b841456a7890b24546504d1059157dc33e700b14d9d3073d8"}, + {file = "pyobjc_framework_coremedia-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ee7b822c9bb674b5b0a70bfb133410acae354e9241b6983f075395f3562f3c46"}, + {file = "pyobjc_framework_coremedia-12.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:161a627f5c8cd30a5ebb935189f740e21e6cd94871a9afd463efdb5d51e255fa"}, + {file = "pyobjc_framework_coremedia-12.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:98e885b7a092083fceaef0a7fc406a01ba7bcd3318fb927e59e055931c99cac8"}, + {file = "pyobjc_framework_coremedia-12.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:d2b84149c1b3e65ec9050a3e5b617e6c0b4cdad2ab622c2d8c5747a20f013e16"}, + {file = "pyobjc_framework_coremedia-12.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:737ec6e0b63414f42f7188030c85975d6d2124fbf6b15b52c99b6cc20250af4d"}, + {file = "pyobjc_framework_coremedia-12.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:6a9419e0d143df16a1562520a13a389417386e2a53031530af6da60c34058ced"}, + {file = "pyobjc_framework_coremedia-12.1.tar.gz", hash = "sha256:166c66a9c01e7a70103f3ca44c571431d124b9070612ef63a1511a4e6d9d84a7"}, +] + +[[package]] +name = "pyobjc-framework-coremedia" +version = "11.1" +requires_python = ">=3.9" +summary = "Wrappers for the framework CoreMedia on macOS" +groups = ["default"] +marker = "python_version < \"3.10\" and python_version >= \"3.9\" and platform_system == \"Darwin\"" +dependencies = [ + "pyobjc-core>=11.1", + "pyobjc-framework-Cocoa>=11.1", +] +files = [ + {file = "pyobjc_framework_coremedia-11.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:c91e5084f88f5716ad3d390af29ad52b8497a73df91dc6d05ebaa293ce3634cc"}, + {file = "pyobjc_framework_coremedia-11.1.tar.gz", hash = "sha256:82cdc087f61e21b761e677ea618a575d4c0dbe00e98230bf9cea540cff931db3"}, +] + +[[package]] +name = "pyobjc-framework-quartz" +version = "12.1" +requires_python = ">=3.10" +summary = "Wrappers for the Quartz frameworks on macOS" +groups = ["default"] +marker = "platform_system == \"Darwin\" and python_version ~= \"3.10\"" +dependencies = [ + "pyobjc-core>=12.1", + "pyobjc-framework-Cocoa>=12.1", +] +files = [ + {file = "pyobjc_framework_quartz-12.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:c6f312ae79ef8b3019dcf4b3374c52035c7c7bc4a09a1748b61b041bb685a0ed"}, + {file = "pyobjc_framework_quartz-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:19f99ac49a0b15dd892e155644fe80242d741411a9ed9c119b18b7466048625a"}, + {file = "pyobjc_framework_quartz-12.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:7730cdce46c7e985535b5a42c31381af4aa6556e5642dc55b5e6597595e57a16"}, + {file = "pyobjc_framework_quartz-12.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:629b7971b1b43a11617f1460cd218bd308dfea247cd4ee3842eb40ca6f588860"}, + {file = "pyobjc_framework_quartz-12.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:53b84e880c358ba1ddcd7e8d5ea0407d760eca58b96f0d344829162cda5f37b3"}, + {file = "pyobjc_framework_quartz-12.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:42d306b07f05ae7d155984503e0fb1b701fecd31dcc5c79fe8ab9790ff7e0de0"}, + {file = "pyobjc_framework_quartz-12.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0cc08fddb339b2760df60dea1057453557588908e42bdc62184b6396ce2d6e9a"}, + {file = "pyobjc_framework_quartz-12.1.tar.gz", hash = "sha256:27f782f3513ac88ec9b6c82d9767eef95a5cf4175ce88a1e5a65875fee799608"}, +] + +[[package]] +name = "pyobjc-framework-quartz" +version = "11.1" +requires_python = ">=3.9" +summary = "Wrappers for the Quartz frameworks on macOS" +groups = ["default"] +marker = "python_version < \"3.10\" and python_version >= \"3.9\" and platform_system == \"Darwin\"" +dependencies = [ + "pyobjc-core>=11.1", + "pyobjc-framework-Cocoa>=11.1", +] +files = [ + {file = "pyobjc_framework_quartz-11.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:9b1f451ddb5243d8d6316af55f240a02b0fffbfe165bff325628bf73f3df7f44"}, + {file = "pyobjc_framework_quartz-11.1.tar.gz", hash = "sha256:a57f35ccfc22ad48c87c5932818e583777ff7276605fef6afad0ac0741169f75"}, +] + [[package]] name = "pyparsing" version = "3.2.5" @@ -1013,12 +1740,34 @@ files = [ {file = "pyserial-3.5.tar.gz", hash = "sha256:3c77e014170dfffbd816e6ffc205e9842efb10be9f58ec16d3e8675b4925cddb"}, ] +[[package]] +name = "pytest" +version = "9.0.1" +requires_python = ">=3.10" +summary = "pytest: simple powerful testing with Python" +groups = ["all", "tests"] +marker = "python_version ~= \"3.10\"" +dependencies = [ + "colorama>=0.4; sys_platform == \"win32\"", + "exceptiongroup>=1; python_version < \"3.11\"", + "iniconfig>=1.0.1", + "packaging>=22", + "pluggy<2,>=1.5", + "pygments>=2.7.2", + "tomli>=1; python_version < \"3.11\"", +] +files = [ + {file = "pytest-9.0.1-py3-none-any.whl", hash = "sha256:67be0030d194df2dfa7b556f2e56fb3c3315bd5c8822c6951162b92b32ce7dad"}, + {file = "pytest-9.0.1.tar.gz", hash = "sha256:3e9c069ea73583e255c3b21cf46b8d3c56f6e3a1a8f6da94ccb0fcf57b9d73c8"}, +] + [[package]] name = "pytest" version = "8.4.2" requires_python = ">=3.9" summary = "pytest: simple powerful testing with Python" groups = ["all", "tests"] +marker = "python_version < \"3.10\" and python_version >= \"3.9\"" dependencies = [ "colorama>=0.4; sys_platform == \"win32\"", "exceptiongroup>=1; python_version < \"3.11\"", @@ -1090,13 +1839,13 @@ files = [ [[package]] name = "pytokens" -version = "0.1.10" +version = "0.3.0" requires_python = ">=3.8" -summary = "A Fast, spec compliant Python 3.12+ tokenizer that runs on older Pythons." +summary = "A Fast, spec compliant Python 3.14+ tokenizer that runs on older Pythons." groups = ["all", "dev"] files = [ - {file = "pytokens-0.1.10-py3-none-any.whl", hash = "sha256:db7b72284e480e69fb085d9f251f66b3d2df8b7166059261258ff35f50fb711b"}, - {file = "pytokens-0.1.10.tar.gz", hash = "sha256:c9a4bfa0be1d26aebce03e6884ba454e842f186a59ea43a6d3b25af58223c044"}, + {file = "pytokens-0.3.0-py3-none-any.whl", hash = "sha256:95b2b5eaf832e469d141a378872480ede3f251a5a5041b8ec6e581d3ac71bbf3"}, + {file = "pytokens-0.3.0.tar.gz", hash = "sha256:2f932b14ed08de5fcf0b391ace2642f858f1394c0857202959000b68ed7a458a"}, ] [[package]] @@ -1111,21 +1860,77 @@ files = [ [[package]] name = "pyyaml" -version = "6.0.2" +version = "6.0.3" requires_python = ">=3.8" summary = "YAML parser and emitter for Python" groups = ["default", "all", "dev", "docs"] files = [ - {file = "PyYAML-6.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:688ba32a1cffef67fd2e9398a2efebaea461578b0923624778664cc1c914db5d"}, - {file = "PyYAML-6.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8786accb172bd8afb8be14490a16625cbc387036876ab6ba70912730faf8e1f"}, - {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e03406cac8513435335dbab54c0d385e4a49e4945d2909a581c83647ca0290"}, - {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12"}, - {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b1fdb9dc17f5a7677423d508ab4f243a726dea51fa5e70992e59a7411c89d19"}, - {file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0b69e4ce7a131fe56b7e4d770c67429700908fc0752af059838b1cfb41960e4e"}, - {file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a9f8c2e67970f13b16084e04f134610fd1d374bf477b17ec1599185cf611d725"}, - {file = "PyYAML-6.0.2-cp39-cp39-win32.whl", hash = "sha256:6395c297d42274772abc367baaa79683958044e5d3835486c16da75d2a694631"}, - {file = "PyYAML-6.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:39693e1f8320ae4f43943590b49779ffb98acb81f788220ea932a6b6c51004d8"}, - {file = "pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e"}, + {file = "pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b"}, + {file = "pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956"}, + {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8"}, + {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198"}, + {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b"}, + {file = "pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0"}, + {file = "pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69"}, + {file = "pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e"}, + {file = "pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c"}, + {file = "pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e"}, + {file = "pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824"}, + {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c"}, + {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00"}, + {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d"}, + {file = "pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a"}, + {file = "pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4"}, + {file = "pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b"}, + {file = "pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf"}, + {file = "pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196"}, + {file = "pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0"}, + {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28"}, + {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c"}, + {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc"}, + {file = "pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e"}, + {file = "pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea"}, + {file = "pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5"}, + {file = "pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b"}, + {file = "pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd"}, + {file = "pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8"}, + {file = "pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1"}, + {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c"}, + {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5"}, + {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6"}, + {file = "pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6"}, + {file = "pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be"}, + {file = "pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26"}, + {file = "pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c"}, + {file = "pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb"}, + {file = "pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac"}, + {file = "pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310"}, + {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7"}, + {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788"}, + {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5"}, + {file = "pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764"}, + {file = "pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35"}, + {file = "pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac"}, + {file = "pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3"}, + {file = "pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3"}, + {file = "pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba"}, + {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c"}, + {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702"}, + {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c"}, + {file = "pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065"}, + {file = "pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65"}, + {file = "pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9"}, + {file = "pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b"}, + {file = "pyyaml-6.0.3-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da"}, + {file = "pyyaml-6.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917"}, + {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9"}, + {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5"}, + {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a"}, + {file = "pyyaml-6.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926"}, + {file = "pyyaml-6.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7"}, + {file = "pyyaml-6.0.3-cp39-cp39-win32.whl", hash = "sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0"}, + {file = "pyyaml-6.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007"}, + {file = "pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f"}, ] [[package]] @@ -1147,7 +1952,7 @@ files = [ [[package]] name = "rich" -version = "14.1.0" +version = "14.2.0" requires_python = ">=3.8.0" summary = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" groups = ["default"] @@ -1156,36 +1961,36 @@ dependencies = [ "pygments<3.0.0,>=2.13.0", ] files = [ - {file = "rich-14.1.0-py3-none-any.whl", hash = "sha256:536f5f1785986d6dbdea3c75205c473f970777b4a0d6c6dd1b696aa05a3fa04f"}, - {file = "rich-14.1.0.tar.gz", hash = "sha256:e497a48b844b0320d45007cdebfeaeed8db2a4f4bcf49f15e455cfc4af11eaa8"}, + {file = "rich-14.2.0-py3-none-any.whl", hash = "sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd"}, + {file = "rich-14.2.0.tar.gz", hash = "sha256:73ff50c7c0c1c77c8243079283f4edb376f0f6442433aecb8ce7e6d0b92d1fe4"}, ] [[package]] name = "ruff" -version = "0.13.1" +version = "0.14.6" requires_python = ">=3.7" summary = "An extremely fast Python linter and code formatter, written in Rust." groups = ["all", "dev"] files = [ - {file = "ruff-0.13.1-py3-none-linux_armv6l.whl", hash = "sha256:b2abff595cc3cbfa55e509d89439b5a09a6ee3c252d92020bd2de240836cf45b"}, - {file = "ruff-0.13.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:4ee9f4249bf7f8bb3984c41bfaf6a658162cdb1b22e3103eabc7dd1dc5579334"}, - {file = "ruff-0.13.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:5c5da4af5f6418c07d75e6f3224e08147441f5d1eac2e6ce10dcce5e616a3bae"}, - {file = "ruff-0.13.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:80524f84a01355a59a93cef98d804e2137639823bcee2931f5028e71134a954e"}, - {file = "ruff-0.13.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ff7f5ce8d7988767dd46a148192a14d0f48d1baea733f055d9064875c7d50389"}, - {file = "ruff-0.13.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c55d84715061f8b05469cdc9a446aa6c7294cd4bd55e86a89e572dba14374f8c"}, - {file = "ruff-0.13.1-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:ac57fed932d90fa1624c946dc67a0a3388d65a7edc7d2d8e4ca7bddaa789b3b0"}, - {file = "ruff-0.13.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c366a71d5b4f41f86a008694f7a0d75fe409ec298685ff72dc882f882d532e36"}, - {file = "ruff-0.13.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f4ea9d1b5ad3e7a83ee8ebb1229c33e5fe771e833d6d3dcfca7b77d95b060d38"}, - {file = "ruff-0.13.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b0f70202996055b555d3d74b626406476cc692f37b13bac8828acff058c9966a"}, - {file = "ruff-0.13.1-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:f8cff7a105dad631085d9505b491db33848007d6b487c3c1979dd8d9b2963783"}, - {file = "ruff-0.13.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:9761e84255443316a258dd7dfbd9bfb59c756e52237ed42494917b2577697c6a"}, - {file = "ruff-0.13.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:3d376a88c3102ef228b102211ef4a6d13df330cb0f5ca56fdac04ccec2a99700"}, - {file = "ruff-0.13.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:cbefd60082b517a82c6ec8836989775ac05f8991715d228b3c1d86ccc7df7dae"}, - {file = "ruff-0.13.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:dd16b9a5a499fe73f3c2ef09a7885cb1d97058614d601809d37c422ed1525317"}, - {file = "ruff-0.13.1-py3-none-win32.whl", hash = "sha256:55e9efa692d7cb18580279f1fbb525146adc401f40735edf0aaeabd93099f9a0"}, - {file = "ruff-0.13.1-py3-none-win_amd64.whl", hash = "sha256:3a3fb595287ee556de947183489f636b9f76a72f0fa9c028bdcabf5bab2cc5e5"}, - {file = "ruff-0.13.1-py3-none-win_arm64.whl", hash = "sha256:c0bae9ffd92d54e03c2bf266f466da0a65e145f298ee5b5846ed435f6a00518a"}, - {file = "ruff-0.13.1.tar.gz", hash = "sha256:88074c3849087f153d4bb22e92243ad4c1b366d7055f98726bc19aa08dc12d51"}, + {file = "ruff-0.14.6-py3-none-linux_armv6l.whl", hash = "sha256:d724ac2f1c240dbd01a2ae98db5d1d9a5e1d9e96eba999d1c48e30062df578a3"}, + {file = "ruff-0.14.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9f7539ea257aa4d07b7ce87aed580e485c40143f2473ff2f2b75aee003186004"}, + {file = "ruff-0.14.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7f6007e55b90a2a7e93083ba48a9f23c3158c433591c33ee2e99a49b889c6332"}, + {file = "ruff-0.14.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a8e7b9d73d8728b68f632aa8e824ef041d068d231d8dbc7808532d3629a6bef"}, + {file = "ruff-0.14.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d50d45d4553a3ebcbd33e7c5e0fe6ca4aafd9a9122492de357205c2c48f00775"}, + {file = "ruff-0.14.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:118548dd121f8a21bfa8ab2c5b80e5b4aed67ead4b7567790962554f38e598ce"}, + {file = "ruff-0.14.6-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:57256efafbfefcb8748df9d1d766062f62b20150691021f8ab79e2d919f7c11f"}, + {file = "ruff-0.14.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ff18134841e5c68f8e5df1999a64429a02d5549036b394fafbe410f886e1989d"}, + {file = "ruff-0.14.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:29c4b7ec1e66a105d5c27bd57fa93203637d66a26d10ca9809dc7fc18ec58440"}, + {file = "ruff-0.14.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:167843a6f78680746d7e226f255d920aeed5e4ad9c03258094a2d49d3028b105"}, + {file = "ruff-0.14.6-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:16a33af621c9c523b1ae006b1b99b159bf5ac7e4b1f20b85b2572455018e0821"}, + {file = "ruff-0.14.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:1432ab6e1ae2dc565a7eea707d3b03a0c234ef401482a6f1621bc1f427c2ff55"}, + {file = "ruff-0.14.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:4c55cfbbe7abb61eb914bfd20683d14cdfb38a6d56c6c66efa55ec6570ee4e71"}, + {file = "ruff-0.14.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:efea3c0f21901a685fff4befda6d61a1bf4cb43de16da87e8226a281d614350b"}, + {file = "ruff-0.14.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:344d97172576d75dc6afc0e9243376dbe1668559c72de1864439c4fc95f78185"}, + {file = "ruff-0.14.6-py3-none-win32.whl", hash = "sha256:00169c0c8b85396516fdd9ce3446c7ca20c2a8f90a77aa945ba6b8f2bfe99e85"}, + {file = "ruff-0.14.6-py3-none-win_amd64.whl", hash = "sha256:390e6480c5e3659f8a4c8d6a0373027820419ac14fa0d2713bd8e6c3e125b8b9"}, + {file = "ruff-0.14.6-py3-none-win_arm64.whl", hash = "sha256:d43c81fbeae52cfa8728d8766bbf46ee4298c888072105815b392da70ca836b2"}, + {file = "ruff-0.14.6.tar.gz", hash = "sha256:6f0c742ca6a7783a736b867a263b9a7a80a45ce9bee391eeda296895f1b4e1cc"}, ] [[package]] @@ -1213,6 +2018,54 @@ marker = "python_version ~= \"3.10\"" dependencies = [ "numpy<2.5,>=1.23.5", ] +files = [ + {file = "scipy-1.15.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:a345928c86d535060c9c2b25e71e87c39ab2f22fc96e9636bd74d1dbf9de448c"}, + {file = "scipy-1.15.3-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:ad3432cb0f9ed87477a8d97f03b763fd1d57709f1bbde3c9369b1dff5503b253"}, + {file = "scipy-1.15.3-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:aef683a9ae6eb00728a542b796f52a5477b78252edede72b8327a886ab63293f"}, + {file = "scipy-1.15.3-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:1c832e1bd78dea67d5c16f786681b28dd695a8cb1fb90af2e27580d3d0967e92"}, + {file = "scipy-1.15.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:263961f658ce2165bbd7b99fa5135195c3a12d9bef045345016b8b50c315cb82"}, + {file = "scipy-1.15.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e2abc762b0811e09a0d3258abee2d98e0c703eee49464ce0069590846f31d40"}, + {file = "scipy-1.15.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ed7284b21a7a0c8f1b6e5977ac05396c0d008b89e05498c8b7e8f4a1423bba0e"}, + {file = "scipy-1.15.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5380741e53df2c566f4d234b100a484b420af85deb39ea35a1cc1be84ff53a5c"}, + {file = "scipy-1.15.3-cp310-cp310-win_amd64.whl", hash = "sha256:9d61e97b186a57350f6d6fd72640f9e99d5a4a2b8fbf4b9ee9a841eab327dc13"}, + {file = "scipy-1.15.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:993439ce220d25e3696d1b23b233dd010169b62f6456488567e830654ee37a6b"}, + {file = "scipy-1.15.3-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:34716e281f181a02341ddeaad584205bd2fd3c242063bd3423d61ac259ca7eba"}, + {file = "scipy-1.15.3-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:3b0334816afb8b91dab859281b1b9786934392aa3d527cd847e41bb6f45bee65"}, + {file = "scipy-1.15.3-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:6db907c7368e3092e24919b5e31c76998b0ce1684d51a90943cb0ed1b4ffd6c1"}, + {file = "scipy-1.15.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:721d6b4ef5dc82ca8968c25b111e307083d7ca9091bc38163fb89243e85e3889"}, + {file = "scipy-1.15.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39cb9c62e471b1bb3750066ecc3a3f3052b37751c7c3dfd0fd7e48900ed52982"}, + {file = "scipy-1.15.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:795c46999bae845966368a3c013e0e00947932d68e235702b5c3f6ea799aa8c9"}, + {file = "scipy-1.15.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:18aaacb735ab38b38db42cb01f6b92a2d0d4b6aabefeb07f02849e47f8fb3594"}, + {file = "scipy-1.15.3-cp311-cp311-win_amd64.whl", hash = "sha256:ae48a786a28412d744c62fd7816a4118ef97e5be0bee968ce8f0a2fba7acf3bb"}, + {file = "scipy-1.15.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6ac6310fdbfb7aa6612408bd2f07295bcbd3fda00d2d702178434751fe48e019"}, + {file = "scipy-1.15.3-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:185cd3d6d05ca4b44a8f1595af87f9c372bb6acf9c808e99aa3e9aa03bd98cf6"}, + {file = "scipy-1.15.3-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:05dc6abcd105e1a29f95eada46d4a3f251743cfd7d3ae8ddb4088047f24ea477"}, + {file = "scipy-1.15.3-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:06efcba926324df1696931a57a176c80848ccd67ce6ad020c810736bfd58eb1c"}, + {file = "scipy-1.15.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c05045d8b9bfd807ee1b9f38761993297b10b245f012b11b13b91ba8945f7e45"}, + {file = "scipy-1.15.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:271e3713e645149ea5ea3e97b57fdab61ce61333f97cfae392c28ba786f9bb49"}, + {file = "scipy-1.15.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6cfd56fc1a8e53f6e89ba3a7a7251f7396412d655bca2aa5611c8ec9a6784a1e"}, + {file = "scipy-1.15.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0ff17c0bb1cb32952c09217d8d1eed9b53d1463e5f1dd6052c7857f83127d539"}, + {file = "scipy-1.15.3-cp312-cp312-win_amd64.whl", hash = "sha256:52092bc0472cfd17df49ff17e70624345efece4e1a12b23783a1ac59a1b728ed"}, + {file = "scipy-1.15.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2c620736bcc334782e24d173c0fdbb7590a0a436d2fdf39310a8902505008759"}, + {file = "scipy-1.15.3-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:7e11270a000969409d37ed399585ee530b9ef6aa99d50c019de4cb01e8e54e62"}, + {file = "scipy-1.15.3-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:8c9ed3ba2c8a2ce098163a9bdb26f891746d02136995df25227a20e71c396ebb"}, + {file = "scipy-1.15.3-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:0bdd905264c0c9cfa74a4772cdb2070171790381a5c4d312c973382fc6eaf730"}, + {file = "scipy-1.15.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:79167bba085c31f38603e11a267d862957cbb3ce018d8b38f79ac043bc92d825"}, + {file = "scipy-1.15.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c9deabd6d547aee2c9a81dee6cc96c6d7e9a9b1953f74850c179f91fdc729cb7"}, + {file = "scipy-1.15.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:dde4fc32993071ac0c7dd2d82569e544f0bdaff66269cb475e0f369adad13f11"}, + {file = "scipy-1.15.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f77f853d584e72e874d87357ad70f44b437331507d1c311457bed8ed2b956126"}, + {file = "scipy-1.15.3-cp313-cp313-win_amd64.whl", hash = "sha256:b90ab29d0c37ec9bf55424c064312930ca5f4bde15ee8619ee44e69319aab163"}, + {file = "scipy-1.15.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:3ac07623267feb3ae308487c260ac684b32ea35fd81e12845039952f558047b8"}, + {file = "scipy-1.15.3-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:6487aa99c2a3d509a5227d9a5e889ff05830a06b2ce08ec30df6d79db5fcd5c5"}, + {file = "scipy-1.15.3-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:50f9e62461c95d933d5c5ef4a1f2ebf9a2b4e83b0db374cb3f1de104d935922e"}, + {file = "scipy-1.15.3-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:14ed70039d182f411ffc74789a16df3835e05dc469b898233a245cdfd7f162cb"}, + {file = "scipy-1.15.3-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a769105537aa07a69468a0eefcd121be52006db61cdd8cac8a0e68980bbb723"}, + {file = "scipy-1.15.3-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9db984639887e3dffb3928d118145ffe40eff2fa40cb241a306ec57c219ebbbb"}, + {file = "scipy-1.15.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:40e54d5c7e7ebf1aa596c374c49fa3135f04648a0caabcb66c52884b943f02b4"}, + {file = "scipy-1.15.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:5e721fed53187e71d0ccf382b6bf977644c533e506c4d33c3fb24de89f5c3ed5"}, + {file = "scipy-1.15.3-cp313-cp313t-win_amd64.whl", hash = "sha256:76ad1fb5f8752eabf0fa02e4cc0336b4e8f021e2d5f061ed37d6d264db35e3ca"}, + {file = "scipy-1.15.3.tar.gz", hash = "sha256:eae3cf522bc7df64b42cad3925c876e1b0b6c35c1337c93e12c0f366f55b0eaf"}, +] [[package]] name = "scipy" @@ -1293,6 +2146,10 @@ dependencies = [ "sphinxcontrib-serializinghtml>=1.1.9", "tomli>=2; python_version < \"3.11\"", ] +files = [ + {file = "sphinx-8.1.3-py3-none-any.whl", hash = "sha256:09719015511837b76bf6e03e42eb7595ac8c2e41eeb9c29c5b755c6b677992a2"}, + {file = "sphinx-8.1.3.tar.gz", hash = "sha256:43c1911eecb0d3e161ad78611bc905d1ad0e523e4ddc202a58a821773dc4c927"}, +] [[package]] name = "sphinx" @@ -1352,6 +2209,10 @@ dependencies = [ "docutils", "sphinx>=4.0", ] +files = [ + {file = "sphinx_click-6.1.0-py3-none-any.whl", hash = "sha256:7dbed856c3d0be75a394da444850d5fc7ecc5694534400aa5ed4f4849a8643f9"}, + {file = "sphinx_click-6.1.0.tar.gz", hash = "sha256:c702e0751c1a0b6ad649e4f7faebd0dc09a3cc7ca3b50f959698383772f50eef"}, +] [[package]] name = "sphinx-click" @@ -1514,7 +2375,7 @@ files = [ [[package]] name = "typing-inspection" -version = "0.4.1" +version = "0.4.2" requires_python = ">=3.9" summary = "Runtime typing introspection tools" groups = ["default", "all", "docs"] @@ -1522,8 +2383,8 @@ dependencies = [ "typing-extensions>=4.12.0", ] files = [ - {file = "typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51"}, - {file = "typing_inspection-0.4.1.tar.gz", hash = "sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28"}, + {file = "typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7"}, + {file = "typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464"}, ] [[package]] diff --git a/pyproject.toml b/pyproject.toml index 31e555b3..c858234a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,7 @@ dependencies = [ 'typing-extensions>=4.10.0; python_version<"3.13"', "scikit-video>=1.1.11", "scipy>=1.13.0", + "cv2-enumerate-cameras>=0.1.0", ] readme = "README.md" From 6c5b8ad65e02e30960e3ffc00c1091de38e2c4f7 Mon Sep 17 00:00:00 2001 From: t-sasatani Date: Tue, 25 Nov 2025 18:14:24 -0800 Subject: [PATCH 03/31] Add camera selection to cli because camera search on win doesn't work --- mio/behavior_cam.py | 4 +-- mio/cli/usbcam.py | 37 ++++++++++++++++---------- mio/data/config/camera/elp-camera.yaml | 4 +-- mio/devices/usbcam.py | 15 +++++++---- 4 files changed, 37 insertions(+), 23 deletions(-) diff --git a/mio/behavior_cam.py b/mio/behavior_cam.py index 1e7af522..7d7382fd 100644 --- a/mio/behavior_cam.py +++ b/mio/behavior_cam.py @@ -217,8 +217,8 @@ def capture( fps = frames_in_window / window_elapsed total_elapsed = current_time - start_time self.logger.info( - f"FPS: {fps:.2f} | Frames: {frames_written} | " - f"Time: {total_elapsed:.1f}s" + f"\nFPS:\t{fps:.2f}\nFrames:\t{frames_written} \n" + f"Time:\t{total_elapsed:.1f}s \n" ) last_fps_log_time = current_time frames_in_window = 0 diff --git a/mio/cli/usbcam.py b/mio/cli/usbcam.py index 35af2404..5ef1551a 100644 --- a/mio/cli/usbcam.py +++ b/mio/cli/usbcam.py @@ -40,7 +40,13 @@ def usbcam() -> None: type=click.Path(), help="Override output directory from config (optional)", ) -def record(config: str, output_dir: Optional[str]) -> None: +@click.option( + "-i", + "--index", + type=int, + help="Specify camera index (optional)", +) +def record(config: str, output_dir: Optional[str], index: Optional[int]) -> None: """Record video with Unix timestamp filename""" recording_config = USBCameraRecordingConfig.from_any(config) @@ -48,21 +54,24 @@ def record(config: str, output_dir: Optional[str]) -> None: if output_dir is not None: recording_config.output_dir = output_dir - # Get available cameras and always prompt for selection - cameras = list_available_cameras() - if not cameras: - raise click.ClickException("No cameras found. Please connect a camera and try again.") + if index is not None: + camera_index = index + else: + # Get available cameras and prompt for selection + cameras = list_available_cameras() + if not cameras: + raise click.ClickException("No cameras found. Please connect a camera and try again.") - click.echo("Available cameras:") - for idx, info in cameras.items(): - click.echo(f" {format_camera_info(idx, info)}") + click.echo("Available cameras:") + for idx, info in cameras.items(): + click.echo(f" {format_camera_info(idx, info)}") - selected_index = click.prompt( - "Select camera index", - type=click.Choice([str(idx) for idx in cameras], case_sensitive=False), - default=str(min(cameras.keys())), - ) - camera_index = int(selected_index) + selected_index = click.prompt( + "Select camera index", + type=click.Choice([str(idx) for idx in cameras], case_sensitive=False), + default=str(min(cameras.keys())), + ) + camera_index = int(selected_index) behavior_cam = BehaviorCam(recording_config=recording_config, camera_index=camera_index) try: diff --git a/mio/data/config/camera/elp-camera.yaml b/mio/data/config/camera/elp-camera.yaml index 81506933..fd211fa1 100644 --- a/mio/data/config/camera/elp-camera.yaml +++ b/mio/data/config/camera/elp-camera.yaml @@ -2,7 +2,7 @@ id: elp-camera mio_model: mio.models.usbcam.USBCameraRecordingConfig mio_version: 0.8.2.dev13+g338e847.d20251030 output_dir: user_data/recordings -frame_width: 1920 -frame_height: 1080 +frame_width: 1280 +frame_height: 720 fps: 20 format: MJPEG diff --git a/mio/devices/usbcam.py b/mio/devices/usbcam.py index 4aa4987a..cee4b063 100644 --- a/mio/devices/usbcam.py +++ b/mio/devices/usbcam.py @@ -139,10 +139,18 @@ def list_cameras() -> Dict[int, Dict[str, str]]: """ List available cameras with name, resolution, and fps. + .. note:: + **Windows Limitation**: Camera enumeration on Windows has known issues where + checking one camera can interfere with detecting others. This function may + only find one camera per run on Windows systems. If you have multiple cameras, + you may need to check them individually or run the enumeration multiple times. + Returns: Dictionary mapping camera index (0, 1, 2...) to camera info. - Prefers standard indices (0, 1, 2...) over backend-specific high indices. """ + available_cameras: Dict[int, Dict[str, str]] = {} + found_cameras: Dict[tuple[str, str], int] = {} # (resolution, fps) -> index + # Get camera names from cv2-enumerate-cameras enumerated_cameras: Dict[int, str] = {} try: @@ -151,10 +159,7 @@ def list_cameras() -> Dict[int, Dict[str, str]]: except Exception: pass - # First, check standard indices (0-9) - prefer these over high backend indices - available_cameras: Dict[int, Dict[str, str]] = {} - found_cameras: Dict[tuple[str, str, str], int] = {} # (resolution, fps, name) -> index - + # Check standard indices (0-9) - prefer these over high backend indices for i in range(MAX_CAMERA_INDEX): cap = cv2.VideoCapture(i) if cap.isOpened(): From 1f194c2b19901948874c0628354ba751417cf80d Mon Sep 17 00:00:00 2001 From: t-sasatani Date: Wed, 26 Nov 2025 10:14:06 -0800 Subject: [PATCH 04/31] Add NTP server check for behavior recording --- mio/cli/usbcam.py | 7 +++++++ mio/models/usbcam.py | 9 +++++++++ 2 files changed, 16 insertions(+) diff --git a/mio/cli/usbcam.py b/mio/cli/usbcam.py index 5ef1551a..87c7983c 100644 --- a/mio/cli/usbcam.py +++ b/mio/cli/usbcam.py @@ -11,6 +11,7 @@ from mio.devices.usbcam import format_camera_info from mio.devices.usbcam import list_cameras as list_available_cameras from mio.models.usbcam import USBCameraRecordingConfig +from mio.ntp import prompt_ntp_sync @click.group() @@ -50,6 +51,12 @@ def record(config: str, output_dir: Optional[str], index: Optional[int]) -> None """Record video with Unix timestamp filename""" recording_config = USBCameraRecordingConfig.from_any(config) + # Check NTP sync if configured + if recording_config.ntp_server is not None: + prompt_ntp_sync( + recording_config.ntp_server, max_offset_seconds=recording_config.ntp_max_offset_seconds + ) + # Override output_dir if provided via CLI if output_dir is not None: recording_config.output_dir = output_dir diff --git a/mio/models/usbcam.py b/mio/models/usbcam.py index bf4a2e33..d1e65188 100644 --- a/mio/models/usbcam.py +++ b/mio/models/usbcam.py @@ -43,3 +43,12 @@ class USBCameraRecordingConfig(MiniscopeConfig, ConfigYAMLMixin): default=None, description="USB product ID of the camera (for reference/documentation).", ) + ntp_server: Optional[str] = Field( + default=None, + description="NTP server address for time synchronization check. " + "If specified, the system time will be verified against this server before capture.", + ) + ntp_max_offset_seconds: float = Field( + default=0.01, + description="Maximum allowed time offset in seconds for NTP synchronization check (default: 0.01 = 10ms).", + ) From f4f31f657aa38bdb73f4c07ff3d37bac1d321693 Mon Sep 17 00:00:00 2001 From: t-sasatani Date: Wed, 26 Nov 2025 10:31:05 -0800 Subject: [PATCH 05/31] Fix FPS measurement timing, linting --- mio/behavior_cam.py | 9 ++++++--- mio/data/config/camera/elp-camera.yaml | 2 ++ mio/models/usbcam.py | 3 ++- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/mio/behavior_cam.py b/mio/behavior_cam.py index 7d7382fd..8772a703 100644 --- a/mio/behavior_cam.py +++ b/mio/behavior_cam.py @@ -165,8 +165,8 @@ def capture( frames_written = 0 frame_index = 0 first_frame = True - start_time = time.time() - last_fps_log_time = start_time + start_time = None + last_fps_log_time = None frames_in_window = 0 writer_used = False @@ -183,12 +183,15 @@ def capture( frame, unix_time = frame_data - # Get actual dimensions from first frame + # Get actual dimensions from first frame and initialize timing if first_frame: actual_height, actual_width = frame.shape[:2] self.logger.info( f"Resolution: {actual_width}x{actual_height} @ {actual_fps}fps" ) + # Start FPS counting from the first grabbed frame + start_time = unix_time + last_fps_log_time = unix_time first_frame = False # Convert frame based on codec diff --git a/mio/data/config/camera/elp-camera.yaml b/mio/data/config/camera/elp-camera.yaml index fd211fa1..8b781d8f 100644 --- a/mio/data/config/camera/elp-camera.yaml +++ b/mio/data/config/camera/elp-camera.yaml @@ -6,3 +6,5 @@ frame_width: 1280 frame_height: 720 fps: 20 format: MJPEG +ntp_server: mio-ntp.local +ntp_max_offset_seconds: 0.01 diff --git a/mio/models/usbcam.py b/mio/models/usbcam.py index d1e65188..5f175de5 100644 --- a/mio/models/usbcam.py +++ b/mio/models/usbcam.py @@ -50,5 +50,6 @@ class USBCameraRecordingConfig(MiniscopeConfig, ConfigYAMLMixin): ) ntp_max_offset_seconds: float = Field( default=0.01, - description="Maximum allowed time offset in seconds for NTP synchronization check (default: 0.01 = 10ms).", + description="Maximum allowed time offset in seconds for NTP synchronization check " + "(default: 0.01 = 10ms).", ) From e1b875ad65e66ade805671575f5b890cd743f7ca Mon Sep 17 00:00:00 2001 From: t-sasatani Date: Thu, 27 Nov 2025 15:22:06 -0800 Subject: [PATCH 06/31] make h264 default for behavior, directly specify pix_fmt, simplify find --- mio/behavior_cam.py | 19 +++++-- mio/cli/usbcam.py | 38 +++++++++----- mio/data/config/camera/elp-camera.yaml | 3 +- mio/data/config/camera/usbcam_mbp.yaml | 3 +- mio/devices/usbcam.py | 71 +++----------------------- mio/models/usbcam.py | 9 ++-- 6 files changed, 53 insertions(+), 90 deletions(-) diff --git a/mio/behavior_cam.py b/mio/behavior_cam.py index 8772a703..8480e267 100644 --- a/mio/behavior_cam.py +++ b/mio/behavior_cam.py @@ -12,7 +12,7 @@ import cv2 from mio import init_logger -from mio.devices.usbcam import convert_frame_for_codec, determine_pix_fmt, open_camera +from mio.devices.usbcam import convert_frame_for_codec, open_camera from mio.io import BufferedCSVWriter, VideoWriter from mio.models.usbcam import USBCameraRecordingConfig from mio.types import ConfigSource @@ -120,7 +120,16 @@ def capture( # Create video writer with Unix timestamp filename timestamp = int(time.time()) # seconds (for filename) - video_path = Path(output_dir) / f"{timestamp}.avi" + + # Determine container format based on codec + if self.config.codec.lower() in ["libx264", "h264"]: + video_ext = ".mp4" + container_format = "mp4" + else: + video_ext = ".avi" + container_format = "avi" + + video_path = Path(output_dir) / f"{timestamp}{video_ext}" csv_path = Path(output_dir) / f"{timestamp}.csv" # Get actual resolution and fps (may differ from requested) @@ -129,15 +138,15 @@ def capture( actual_width = self.config.frame_width actual_height = self.config.frame_height - # Determine pixel format from config or auto-detect based on codec - pix_fmt = determine_pix_fmt(self.config.codec, self.config.pix_fmt) + # Use pixel format from config + pix_fmt = self.config.pix_fmt writer = VideoWriter( path=video_path, fps=actual_fps, output_dict={ "-vcodec": self.config.codec, - "-f": "avi", + "-f": container_format, "-pix_fmt": pix_fmt, }, ) diff --git a/mio/cli/usbcam.py b/mio/cli/usbcam.py index 87c7983c..66597073 100644 --- a/mio/cli/usbcam.py +++ b/mio/cli/usbcam.py @@ -14,12 +14,33 @@ from mio.ntp import prompt_ntp_sync -@click.group() -def usbcam() -> None: +@click.group(invoke_without_command=True) +@click.option( + "--list", + "list_cameras_flag", + is_flag=True, + help="List available cameras and exit", +) +@click.pass_context +def usbcam(ctx: click.Context, list_cameras_flag: bool) -> None: """ Command group for USB Camera """ - pass + # Handle --list flag + if list_cameras_flag: + cameras = list_available_cameras() + if not cameras: + click.echo("No cameras found") + else: + click.echo("Available cameras:") + for idx, info in cameras.items(): + click.echo(f" {format_camera_info(idx, info, prefix='Index ')}") + ctx.exit() + + # If no subcommand was invoked and --list wasn't used, show help + if ctx.invoked_subcommand is None: + click.echo(ctx.get_help()) + ctx.exit() @usbcam.command() @@ -88,14 +109,3 @@ def record(config: str, output_dir: Optional[str], index: Optional[int]) -> None raise click.ClickException(f"Error recording video: {e}") from e -@usbcam.command() -def list_cameras() -> None: - """List available cameras""" - cameras = list_available_cameras() - if not cameras: - click.echo("No cameras found") - return - - click.echo("Available cameras:") - for idx, info in cameras.items(): - click.echo(f" {format_camera_info(idx, info, prefix='Index ')}") diff --git a/mio/data/config/camera/elp-camera.yaml b/mio/data/config/camera/elp-camera.yaml index 8b781d8f..644d499c 100644 --- a/mio/data/config/camera/elp-camera.yaml +++ b/mio/data/config/camera/elp-camera.yaml @@ -5,6 +5,7 @@ output_dir: user_data/recordings frame_width: 1280 frame_height: 720 fps: 20 -format: MJPEG +codec: libx264 +pix_fmt: yuv420p ntp_server: mio-ntp.local ntp_max_offset_seconds: 0.01 diff --git a/mio/data/config/camera/usbcam_mbp.yaml b/mio/data/config/camera/usbcam_mbp.yaml index 67b4536f..5d3ba66e 100644 --- a/mio/data/config/camera/usbcam_mbp.yaml +++ b/mio/data/config/camera/usbcam_mbp.yaml @@ -6,4 +6,5 @@ output_dir: recordings frame_width: 1920 frame_height: 1080 fps: 30 -format: MJPEG +codec: libx264 +pix_fmt: yuv420p diff --git a/mio/devices/usbcam.py b/mio/devices/usbcam.py index cee4b063..cf835d45 100644 --- a/mio/devices/usbcam.py +++ b/mio/devices/usbcam.py @@ -3,11 +3,10 @@ """ import time -from typing import Dict, Optional +from typing import Dict import cv2 import numpy as np -from cv2_enumerate_cameras import enumerate_cameras # Constants MAX_CAMERA_INDEX = 5 @@ -15,27 +14,6 @@ CAMERA_INIT_RETRY_ATTEMPTS = 3 # Number of retry attempts when reading initial frame -def determine_pix_fmt(codec: str, pix_fmt: Optional[str] = None) -> str: - """ - Determine pixel format for video output based on codec. - - Args: - codec: Video codec (e.g., "mjpeg", "rawvideo", "libx264") - pix_fmt: Explicit pixel format override (if provided, returns this) - - Returns: - Pixel format string for FFmpeg - """ - if pix_fmt is not None: - return pix_fmt - elif codec.lower() == "mjpeg": - return "yuvj420p" # YUV color format for MJPEG - elif codec.lower() == "rawvideo": - return "gray" # Grayscale for rawvideo - else: - return "yuv420p" # Default YUV format for other codecs - - def convert_frame_for_codec(frame: np.ndarray, codec: str) -> np.ndarray: """ Convert frame color space based on codec requirements. @@ -139,27 +117,12 @@ def list_cameras() -> Dict[int, Dict[str, str]]: """ List available cameras with name, resolution, and fps. - .. note:: - **Windows Limitation**: Camera enumeration on Windows has known issues where - checking one camera can interfere with detecting others. This function may - only find one camera per run on Windows systems. If you have multiple cameras, - you may need to check them individually or run the enumeration multiple times. - Returns: Dictionary mapping camera index (0, 1, 2...) to camera info. """ available_cameras: Dict[int, Dict[str, str]] = {} - found_cameras: Dict[tuple[str, str], int] = {} # (resolution, fps) -> index - - # Get camera names from cv2-enumerate-cameras - enumerated_cameras: Dict[int, str] = {} - try: - for camera in enumerate_cameras(): - enumerated_cameras[camera.index] = camera.name - except Exception: - pass - # Check standard indices (0-9) - prefer these over high backend indices + # Check standard indices for i in range(MAX_CAMERA_INDEX): cap = cv2.VideoCapture(i) if cap.isOpened(): @@ -167,31 +130,11 @@ def list_cameras() -> Dict[int, Dict[str, str]]: if ret: resolution = f"{frame.shape[1]}x{frame.shape[0]}" fps = int(cap.get(cv2.CAP_PROP_FPS)) - camera_key = (resolution, str(fps)) - - # Try to find matching name from enumerated cameras - name = f"Camera {i}" - for enum_idx, enum_name in enumerated_cameras.items(): - # Check if this standard index matches an enumerated camera - test_cap = cv2.VideoCapture(enum_idx) - if test_cap.isOpened(): - test_ret, test_frame = test_cap.read() - test_cap.release() - if test_ret: - test_res = f"{test_frame.shape[1]}x{test_frame.shape[0]}" - test_fps = int(cv2.VideoCapture(enum_idx).get(cv2.CAP_PROP_FPS)) - if test_res == resolution and str(test_fps) == str(fps): - name = enum_name - break - - # Check if we've already found this camera (duplicate) - if camera_key not in found_cameras: - found_cameras[camera_key] = i - available_cameras[i] = { - "name": name, - "resolution": resolution, - "fps": str(fps), - } + available_cameras[i] = { + "name": f"Camera {i}", + "resolution": resolution, + "fps": str(fps), + } cap.release() else: # Stop checking after first failure (no more cameras) diff --git a/mio/models/usbcam.py b/mio/models/usbcam.py index 5f175de5..3a216e3b 100644 --- a/mio/models/usbcam.py +++ b/mio/models/usbcam.py @@ -27,13 +27,12 @@ class USBCameraRecordingConfig(MiniscopeConfig, ConfigYAMLMixin): "Note: Output video encoding is handled by VideoWriter.", ) codec: str = Field( - default="mjpeg", + default="libx264", description="Video codec for output file (e.g., mjpeg, libx264, rawvideo).", ) - pix_fmt: Optional[str] = Field( - default=None, - description="Pixel format for video encoding (e.g., yuvj420p, yuv420p, gray). " - "If None, automatically determined from codec.", + pix_fmt: str = Field( + default="yuv420p", + description="Pixel format for video encoding (e.g., yuvj420p, yuv420p, gray).", ) vendor_id: int = Field( default=0x32E4, From 5747a1ad44d79fd2c243f25823f9b83e26cf70cf Mon Sep 17 00:00:00 2001 From: t-sasatani Date: Thu, 27 Nov 2025 15:25:03 -0800 Subject: [PATCH 07/31] remove vendor, product id from webcam, lint --- mio/behavior_cam.py | 4 ++-- mio/cli/usbcam.py | 4 +--- mio/models/usbcam.py | 8 -------- 3 files changed, 3 insertions(+), 13 deletions(-) diff --git a/mio/behavior_cam.py b/mio/behavior_cam.py index 8480e267..68d13f5f 100644 --- a/mio/behavior_cam.py +++ b/mio/behavior_cam.py @@ -120,7 +120,7 @@ def capture( # Create video writer with Unix timestamp filename timestamp = int(time.time()) # seconds (for filename) - + # Determine container format based on codec if self.config.codec.lower() in ["libx264", "h264"]: video_ext = ".mp4" @@ -128,7 +128,7 @@ def capture( else: video_ext = ".avi" container_format = "avi" - + video_path = Path(output_dir) / f"{timestamp}{video_ext}" csv_path = Path(output_dir) / f"{timestamp}.csv" diff --git a/mio/cli/usbcam.py b/mio/cli/usbcam.py index 66597073..d686a919 100644 --- a/mio/cli/usbcam.py +++ b/mio/cli/usbcam.py @@ -36,7 +36,7 @@ def usbcam(ctx: click.Context, list_cameras_flag: bool) -> None: for idx, info in cameras.items(): click.echo(f" {format_camera_info(idx, info, prefix='Index ')}") ctx.exit() - + # If no subcommand was invoked and --list wasn't used, show help if ctx.invoked_subcommand is None: click.echo(ctx.get_help()) @@ -107,5 +107,3 @@ def record(config: str, output_dir: Optional[str], index: Optional[int]) -> None except Exception as e: click.echo(f"Error recording video: {e}", err=True) raise click.ClickException(f"Error recording video: {e}") from e - - diff --git a/mio/models/usbcam.py b/mio/models/usbcam.py index 3a216e3b..1a84f4df 100644 --- a/mio/models/usbcam.py +++ b/mio/models/usbcam.py @@ -34,14 +34,6 @@ class USBCameraRecordingConfig(MiniscopeConfig, ConfigYAMLMixin): default="yuv420p", description="Pixel format for video encoding (e.g., yuvj420p, yuv420p, gray).", ) - vendor_id: int = Field( - default=0x32E4, - description="USB vendor ID of the camera (for reference/documentation).", - ) - product_id: Optional[int] = Field( - default=None, - description="USB product ID of the camera (for reference/documentation).", - ) ntp_server: Optional[str] = Field( default=None, description="NTP server address for time synchronization check. " From f9e75ef122fb933788e0a3b1e3e1b1ba4487b743 Mon Sep 17 00:00:00 2001 From: t-sasatani Date: Fri, 28 Nov 2025 15:28:23 -0800 Subject: [PATCH 08/31] disable frame sync to prevent frame drop, back to mjpeg --- mio/behavior_cam.py | 1 + mio/data/config/camera/elp-camera.yaml | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/mio/behavior_cam.py b/mio/behavior_cam.py index 68d13f5f..462e2a0f 100644 --- a/mio/behavior_cam.py +++ b/mio/behavior_cam.py @@ -148,6 +148,7 @@ def capture( "-vcodec": self.config.codec, "-f": container_format, "-pix_fmt": pix_fmt, + "-vsync": "0", # Disable frame sync - write frames as-is (same as StreamDaq) }, ) diff --git a/mio/data/config/camera/elp-camera.yaml b/mio/data/config/camera/elp-camera.yaml index 644d499c..b9176ecc 100644 --- a/mio/data/config/camera/elp-camera.yaml +++ b/mio/data/config/camera/elp-camera.yaml @@ -5,7 +5,7 @@ output_dir: user_data/recordings frame_width: 1280 frame_height: 720 fps: 20 -codec: libx264 -pix_fmt: yuv420p +codec: mjpeg +pix_fmt: yuvj422p ntp_server: mio-ntp.local ntp_max_offset_seconds: 0.01 From b1628cfa868f43f3e326c01604e67306cc07842b Mon Sep 17 00:00:00 2001 From: t-sasatani Date: Fri, 28 Nov 2025 15:43:00 -0800 Subject: [PATCH 09/31] Add optional cv2 backend for videowriter (for preventing frame drops) --- mio/behavior_cam.py | 19 ++++--- mio/data/config/camera/elp-camera.yaml | 5 +- mio/io.py | 69 ++++++++++++++++++++++---- mio/models/usbcam.py | 20 ++++++-- 4 files changed, 89 insertions(+), 24 deletions(-) diff --git a/mio/behavior_cam.py b/mio/behavior_cam.py index 462e2a0f..ec9c55e8 100644 --- a/mio/behavior_cam.py +++ b/mio/behavior_cam.py @@ -138,18 +138,21 @@ def capture( actual_width = self.config.frame_width actual_height = self.config.frame_height - # Use pixel format from config - pix_fmt = self.config.pix_fmt + # Build output_dict (pix_fmt only used by skvideo backend, cv2 ignores it) + output_dict = { + "-vcodec": self.config.codec, + "-f": container_format, + "-vsync": "0", # Disable frame sync - write frames as-is (same as StreamDaq) + } + # Only add pix_fmt for skvideo backend (cv2 doesn't use it) + if self.config.backend == "skvideo" and self.config.pix_fmt is not None: + output_dict["-pix_fmt"] = self.config.pix_fmt writer = VideoWriter( path=video_path, fps=actual_fps, - output_dict={ - "-vcodec": self.config.codec, - "-f": container_format, - "-pix_fmt": pix_fmt, - "-vsync": "0", # Disable frame sync - write frames as-is (same as StreamDaq) - }, + output_dict=output_dict, + backend=self.config.backend, ) csv_writer = BufferedCSVWriter( diff --git a/mio/data/config/camera/elp-camera.yaml b/mio/data/config/camera/elp-camera.yaml index b9176ecc..39faf07c 100644 --- a/mio/data/config/camera/elp-camera.yaml +++ b/mio/data/config/camera/elp-camera.yaml @@ -5,7 +5,8 @@ output_dir: user_data/recordings frame_width: 1280 frame_height: 720 fps: 20 -codec: mjpeg -pix_fmt: yuvj422p +codec: h264 +pix_fmt: yuv420p ntp_server: mio-ntp.local ntp_max_offset_seconds: 0.01 +backend: cv2 \ No newline at end of file diff --git a/mio/io.py b/mio/io.py index e2f97f5f..d8942ebc 100644 --- a/mio/io.py +++ b/mio/io.py @@ -22,7 +22,7 @@ class VideoWriter: """ - Write data to a video file using FFMpegWriter. + Write data to a video file using FFMpegWriter (default) or cv2.VideoWriter. """ DEFAULT_OUTPUT = { @@ -37,17 +37,43 @@ def __init__( path: Union[str, Path], fps: int, output_dict: Union[dict, None] = None, + backend: Literal["skvideo", "cv2"] = "skvideo", ): """ Initialize the VideoWriter object. - """ - if output_dict is None: - output_dict = {} - output_dict = {**self.DEFAULT_OUTPUT, **output_dict} - - input_dict = {"-framerate": str(fps)} - self.writer = FFmpegWriter(filename=str(path), inputdict=input_dict, outputdict=output_dict) + Args: + path: Output video file path + fps: Frames per second + output_dict: FFmpeg output options (for skvideo backend) + backend: Backend to use - "skvideo" (default) or "cv2" + """ + self.path = Path(path) + self.fps = fps + self.backend = backend + + if backend == "cv2": + # cv2 backend - will initialize on first frame + # Map codec to fourcc + codec = output_dict.get("-vcodec", "mjpeg") if output_dict else "mjpeg" + fourcc_map = { + "mjpeg": "MJPG", + "libx264": "mp4v", + "h264": "mp4v", + "rawvideo": "GREY", + } + self.fourcc = fourcc_map.get(codec.lower(), "MJPG") + self.writer = None # Initialize on first frame + self.frame_shape = None + else: + # skvideo backend (default) + if output_dict is None: + output_dict = {} + output_dict = {**self.DEFAULT_OUTPUT, **output_dict} + input_dict = {"-framerate": str(fps)} + self.writer = FFmpegWriter( + filename=str(path), inputdict=input_dict, outputdict=output_dict + ) def write_frame(self, frame: np.ndarray) -> bool: """ @@ -59,14 +85,37 @@ def write_frame(self, frame: np.ndarray) -> bool: Returns: bool: True if the frame write was attempted and did not raise anything. """ - self.writer.writeFrame(frame) + if self.backend == "cv2": + if self.writer is None: + # Initialize cv2.VideoWriter on first frame + height, width = frame.shape[:2] + is_color = len(frame.shape) == 3 + self.writer = cv2.VideoWriter( + str(self.path), + cv2.VideoWriter_fourcc(*self.fourcc), + self.fps, + (width, height), + isColor=is_color, + ) + if not self.writer.isOpened(): + raise RuntimeError(f"Failed to open cv2.VideoWriter for {self.path}") + # cv2.VideoWriter expects BGR, but frames from convert_frame_for_codec are RGB + if len(frame.shape) == 3 and frame.shape[2] == 3: + frame = cv2.cvtColor(frame, cv2.COLOR_RGB2BGR) + self.writer.write(frame) + else: + self.writer.writeFrame(frame) return True def close(self) -> None: """ Close the video file. """ - self.writer.close() + if self.backend == "cv2": + if self.writer is not None: + self.writer.release() + else: + self.writer.close() class VideoReader: diff --git a/mio/models/usbcam.py b/mio/models/usbcam.py index 1a84f4df..46db79d2 100644 --- a/mio/models/usbcam.py +++ b/mio/models/usbcam.py @@ -2,7 +2,7 @@ Models for USB camera recording configuration. """ -from typing import Optional +from typing import Literal, Optional from pydantic import Field @@ -28,11 +28,23 @@ class USBCameraRecordingConfig(MiniscopeConfig, ConfigYAMLMixin): ) codec: str = Field( default="libx264", - description="Video codec for output file (e.g., mjpeg, libx264, rawvideo).", + description=( + "Video codec for output file (e.g., mjpeg, libx264, rawvideo). " + "Used by skvideo backend, mapped to fourcc for cv2 backend." + ), ) - pix_fmt: str = Field( + pix_fmt: Optional[str] = Field( default="yuv420p", - description="Pixel format for video encoding (e.g., yuvj420p, yuv420p, gray).", + description=( + "Pixel format for video encoding (e.g., yuvj420p, yuv420p, gray). " + "Only used by skvideo backend, ignored by cv2 backend." + ), + ) + backend: Literal["skvideo", "cv2"] = Field( + default="skvideo", + description=( + "Video writer backend: 'skvideo' uses FFmpegWriter, " "'cv2' uses cv2.VideoWriter." + ), ) ntp_server: Optional[str] = Field( default=None, From ddd18c98c53139bd380dc38c4e5e818972a445a1 Mon Sep 17 00:00:00 2001 From: t-sasatani Date: Sat, 28 Feb 2026 12:57:33 +0900 Subject: [PATCH 10/31] minor change flag name, etc. --- mio/cli/usbcam.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/mio/cli/usbcam.py b/mio/cli/usbcam.py index d686a919..fcd41a4a 100644 --- a/mio/cli/usbcam.py +++ b/mio/cli/usbcam.py @@ -17,17 +17,16 @@ @click.group(invoke_without_command=True) @click.option( "--list", - "list_cameras_flag", + "list_cameras", is_flag=True, help="List available cameras and exit", ) @click.pass_context -def usbcam(ctx: click.Context, list_cameras_flag: bool) -> None: +def usbcam(ctx: click.Context, list_cameras: bool) -> None: """ Command group for USB Camera """ - # Handle --list flag - if list_cameras_flag: + if list_cameras: cameras = list_available_cameras() if not cameras: click.echo("No cameras found") @@ -37,7 +36,6 @@ def usbcam(ctx: click.Context, list_cameras_flag: bool) -> None: click.echo(f" {format_camera_info(idx, info, prefix='Index ')}") ctx.exit() - # If no subcommand was invoked and --list wasn't used, show help if ctx.invoked_subcommand is None: click.echo(ctx.get_help()) ctx.exit() From b961f42e658caca3c8004195c2595e6c0187ad9a Mon Sep 17 00:00:00 2001 From: t-sasatani Date: Sat, 28 Feb 2026 14:52:09 +0900 Subject: [PATCH 11/31] make codec literal for better validation --- mio/behavior_cam.py | 2 +- mio/devices/usbcam.py | 9 ++++++--- mio/models/usbcam.py | 4 ++-- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/mio/behavior_cam.py b/mio/behavior_cam.py index ec9c55e8..655d8dee 100644 --- a/mio/behavior_cam.py +++ b/mio/behavior_cam.py @@ -122,7 +122,7 @@ def capture( timestamp = int(time.time()) # seconds (for filename) # Determine container format based on codec - if self.config.codec.lower() in ["libx264", "h264"]: + if self.config.codec in ("libx264", "h264"): video_ext = ".mp4" container_format = "mp4" else: diff --git a/mio/devices/usbcam.py b/mio/devices/usbcam.py index cf835d45..ebd08bec 100644 --- a/mio/devices/usbcam.py +++ b/mio/devices/usbcam.py @@ -3,7 +3,7 @@ """ import time -from typing import Dict +from typing import Dict, Literal import cv2 import numpy as np @@ -14,7 +14,10 @@ CAMERA_INIT_RETRY_ATTEMPTS = 3 # Number of retry attempts when reading initial frame -def convert_frame_for_codec(frame: np.ndarray, codec: str) -> np.ndarray: +Codec = Literal["mjpeg", "libx264", "h264", "rawvideo"] + + +def convert_frame_for_codec(frame: np.ndarray, codec: Codec) -> np.ndarray: """ Convert frame color space based on codec requirements. @@ -25,7 +28,7 @@ def convert_frame_for_codec(frame: np.ndarray, codec: str) -> np.ndarray: Returns: Converted frame ready for video writer """ - if codec.lower() == "rawvideo": + if codec == "rawvideo": # Rawvideo expects grayscale if len(frame.shape) == 3: return cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) diff --git a/mio/models/usbcam.py b/mio/models/usbcam.py index 46db79d2..94ba01a9 100644 --- a/mio/models/usbcam.py +++ b/mio/models/usbcam.py @@ -26,10 +26,10 @@ class USBCameraRecordingConfig(MiniscopeConfig, ConfigYAMLMixin): description="Video format for camera capture (e.g., MJPEG, YUY2). " "Note: Output video encoding is handled by VideoWriter.", ) - codec: str = Field( + codec: Literal["mjpeg", "libx264", "h264", "rawvideo"] = Field( default="libx264", description=( - "Video codec for output file (e.g., mjpeg, libx264, rawvideo). " + "Video codec for output file. " "Used by skvideo backend, mapped to fourcc for cv2 backend." ), ) From 08aeda342977bdae2acaffae1145fc5ce4b78be8 Mon Sep 17 00:00:00 2001 From: t-sasatani Date: Sat, 28 Feb 2026 14:54:04 +0900 Subject: [PATCH 12/31] Don't force RGB when codec and shape mismatch --- mio/devices/usbcam.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/mio/devices/usbcam.py b/mio/devices/usbcam.py index ebd08bec..85234d61 100644 --- a/mio/devices/usbcam.py +++ b/mio/devices/usbcam.py @@ -23,7 +23,7 @@ def convert_frame_for_codec(frame: np.ndarray, codec: Codec) -> np.ndarray: Args: frame: Input frame (BGR from OpenCV) - codec: Video codec (e.g., "mjpeg", "rawvideo", "libx264") + codec: Video codec for output encoding Returns: Converted frame ready for video writer @@ -39,8 +39,10 @@ def convert_frame_for_codec(frame: np.ndarray, codec: Codec) -> np.ndarray: if len(frame.shape) == 3: return cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) else: - # If grayscale, convert to RGB - return cv2.cvtColor(frame, cv2.COLOR_GRAY2RGB) + raise ValueError( + f"Expected BGR (3-channel) frame for codec '{codec}', " + f"got shape {frame.shape}. Use 'rawvideo' for grayscale." + ) def open_camera( From d3bb8838fea3b55ca5bc6a302b70e74bc5c10feb Mon Sep 17 00:00:00 2001 From: t-sasatani Date: Sat, 28 Feb 2026 15:09:16 +0900 Subject: [PATCH 13/31] simplify handling of camera metadata --- mio/cli/usbcam.py | 2 +- mio/devices/usbcam.py | 34 ++++++++++++++++------------------ 2 files changed, 17 insertions(+), 19 deletions(-) diff --git a/mio/cli/usbcam.py b/mio/cli/usbcam.py index fcd41a4a..23f5c79a 100644 --- a/mio/cli/usbcam.py +++ b/mio/cli/usbcam.py @@ -33,7 +33,7 @@ def usbcam(ctx: click.Context, list_cameras: bool) -> None: else: click.echo("Available cameras:") for idx, info in cameras.items(): - click.echo(f" {format_camera_info(idx, info, prefix='Index ')}") + click.echo(f" {format_camera_info(idx, info)}") ctx.exit() if ctx.invoked_subcommand is None: diff --git a/mio/devices/usbcam.py b/mio/devices/usbcam.py index 85234d61..4402ecb5 100644 --- a/mio/devices/usbcam.py +++ b/mio/devices/usbcam.py @@ -3,7 +3,7 @@ """ import time -from typing import Dict, Literal +from typing import Dict, Literal, TypedDict import cv2 import numpy as np @@ -17,6 +17,14 @@ Codec = Literal["mjpeg", "libx264", "h264", "rawvideo"] +class CameraInfo(TypedDict): + """Camera information from OpenCV discovery.""" + + name: str + resolution: str + fps: int + + def convert_frame_for_codec(frame: np.ndarray, codec: Codec) -> np.ndarray: """ Convert frame color space based on codec requirements. @@ -80,9 +88,8 @@ def open_camera( # Verify camera is working by reading a test frame # Retry a few times as some cameras need a moment to start ret = False - frame = None for _ in range(CAMERA_INIT_RETRY_ATTEMPTS): - ret, frame = cap.read() + ret, _ = cap.read() if ret: break time.sleep(CAMERA_INIT_DELAY_SECONDS) @@ -98,34 +105,28 @@ def open_camera( return cap -def format_camera_info(idx: int, info: Dict[str, str], prefix: str = "[") -> str: +def format_camera_info(idx: int, info: CameraInfo) -> str: """ Format camera information for display. Args: idx: Camera index - info: Camera info dictionary - prefix: Prefix for index (default: "[" for "[0]", - use "Index " for "Index 0:" format) + info: Camera info from discovery Returns: Formatted string for display """ - name = info.get("name", "Camera") - resolution = info.get("resolution", "Unknown") - fps = info.get("fps", "Unknown") - index_str = f"[{idx}]" if prefix == "[" else f"{prefix}{idx}:" - return f"{index_str} {name} - {resolution} @ {fps} fps" + return f"[{idx}] {info['name']} - {info['resolution']} @ {info['fps']} fps" -def list_cameras() -> Dict[int, Dict[str, str]]: +def list_cameras() -> Dict[int, CameraInfo]: """ List available cameras with name, resolution, and fps. Returns: Dictionary mapping camera index (0, 1, 2...) to camera info. """ - available_cameras: Dict[int, Dict[str, str]] = {} + available_cameras: Dict[int, CameraInfo] = {} # Check standard indices for i in range(MAX_CAMERA_INDEX): @@ -138,11 +139,8 @@ def list_cameras() -> Dict[int, Dict[str, str]]: available_cameras[i] = { "name": f"Camera {i}", "resolution": resolution, - "fps": str(fps), + "fps": fps, } cap.release() - else: - # Stop checking after first failure (no more cameras) - break return available_cameras From c87724ce94d9ed0257ae025905fbacfd3b7f246b Mon Sep 17 00:00:00 2001 From: t-sasatani Date: Sat, 28 Feb 2026 15:12:08 +0900 Subject: [PATCH 14/31] conservative camera scan logic --- mio/devices/usbcam.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/mio/devices/usbcam.py b/mio/devices/usbcam.py index 4402ecb5..dfad5cc6 100644 --- a/mio/devices/usbcam.py +++ b/mio/devices/usbcam.py @@ -8,6 +8,10 @@ import cv2 import numpy as np +from mio.logging import init_logger + +logger = init_logger("usbcam") + # Constants MAX_CAMERA_INDEX = 5 CAMERA_INIT_DELAY_SECONDS = 0.1 # Delay after setting camera properties before reading @@ -128,7 +132,7 @@ def list_cameras() -> Dict[int, CameraInfo]: """ available_cameras: Dict[int, CameraInfo] = {} - # Check standard indices + logger.info(f"Scanning for cameras (indices 0-{MAX_CAMERA_INDEX - 1})...") for i in range(MAX_CAMERA_INDEX): cap = cv2.VideoCapture(i) if cap.isOpened(): @@ -141,6 +145,10 @@ def list_cameras() -> Dict[int, CameraInfo]: "resolution": resolution, "fps": fps, } + else: + logger.debug(f"Camera at index {i} opened but failed to read frame") cap.release() + else: + logger.debug(f"No camera found at index {i}") return available_cameras From c359a51caff09a98a3ed96e604cd0b0c97a10feb Mon Sep 17 00:00:00 2001 From: t-sasatani Date: Sat, 28 Feb 2026 15:16:06 +0900 Subject: [PATCH 15/31] move codec literal to model --- mio/devices/usbcam.py | 6 ++---- mio/models/usbcam.py | 4 +++- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/mio/devices/usbcam.py b/mio/devices/usbcam.py index dfad5cc6..c3ef55d5 100644 --- a/mio/devices/usbcam.py +++ b/mio/devices/usbcam.py @@ -3,12 +3,13 @@ """ import time -from typing import Dict, Literal, TypedDict +from typing import Dict, TypedDict import cv2 import numpy as np from mio.logging import init_logger +from mio.models.usbcam import Codec logger = init_logger("usbcam") @@ -18,9 +19,6 @@ CAMERA_INIT_RETRY_ATTEMPTS = 3 # Number of retry attempts when reading initial frame -Codec = Literal["mjpeg", "libx264", "h264", "rawvideo"] - - class CameraInfo(TypedDict): """Camera information from OpenCV discovery.""" diff --git a/mio/models/usbcam.py b/mio/models/usbcam.py index 94ba01a9..07b42947 100644 --- a/mio/models/usbcam.py +++ b/mio/models/usbcam.py @@ -9,6 +9,8 @@ from mio.models import MiniscopeConfig from mio.models.mixins import ConfigYAMLMixin +Codec = Literal["mjpeg", "libx264", "h264", "rawvideo"] + class USBCameraRecordingConfig(MiniscopeConfig, ConfigYAMLMixin): """ @@ -26,7 +28,7 @@ class USBCameraRecordingConfig(MiniscopeConfig, ConfigYAMLMixin): description="Video format for camera capture (e.g., MJPEG, YUY2). " "Note: Output video encoding is handled by VideoWriter.", ) - codec: Literal["mjpeg", "libx264", "h264", "rawvideo"] = Field( + codec: Codec = Field( default="libx264", description=( "Video codec for output file. " From 8c6765d0c100a34c53b21a3c440fb4e7b3bf67bc Mon Sep 17 00:00:00 2001 From: t-sasatani Date: Sat, 28 Feb 2026 15:20:42 +0900 Subject: [PATCH 16/31] hook up the dead capture format field --- mio/behavior_cam.py | 1 + mio/devices/usbcam.py | 4 ++++ mio/models/usbcam.py | 4 ++-- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/mio/behavior_cam.py b/mio/behavior_cam.py index 655d8dee..dc566842 100644 --- a/mio/behavior_cam.py +++ b/mio/behavior_cam.py @@ -69,6 +69,7 @@ def _camera_recv( frame_width=self.config.frame_width, frame_height=self.config.frame_height, fps=self.config.fps, + capture_format=self.config.format, ) except RuntimeError: frame_queue.put(None) diff --git a/mio/devices/usbcam.py b/mio/devices/usbcam.py index c3ef55d5..3a6ff6af 100644 --- a/mio/devices/usbcam.py +++ b/mio/devices/usbcam.py @@ -60,6 +60,7 @@ def open_camera( frame_width: int, frame_height: int, fps: int, + capture_format: str = "MJPEG", ) -> cv2.VideoCapture: """ Open and configure a camera with the specified settings. @@ -69,6 +70,7 @@ def open_camera( frame_width: Desired frame width frame_height: Desired frame height fps: Desired frames per second + capture_format: Camera capture format (e.g., "MJPEG", "YUY2") Returns: Configured VideoCapture object @@ -80,6 +82,8 @@ def open_camera( if not cap.isOpened(): raise RuntimeError(f"Failed to open camera at index {camera_index}") + fourcc = cv2.VideoWriter_fourcc(*capture_format) + cap.set(cv2.CAP_PROP_FOURCC, fourcc) cap.set(cv2.CAP_PROP_FRAME_WIDTH, frame_width) cap.set(cv2.CAP_PROP_FRAME_HEIGHT, frame_height) cap.set(cv2.CAP_PROP_FPS, fps) diff --git a/mio/models/usbcam.py b/mio/models/usbcam.py index 07b42947..2434b59c 100644 --- a/mio/models/usbcam.py +++ b/mio/models/usbcam.py @@ -23,9 +23,9 @@ class USBCameraRecordingConfig(MiniscopeConfig, ConfigYAMLMixin): frame_width: int = Field(default=1920, description="Width of the recorded video.") frame_height: int = Field(default=1080, description="Height of the recorded video.") fps: int = Field(default=20, description="Frames per second of the recorded video.") - format: str = Field( + format: Literal["MJPEG", "YUY2"] = Field( default="MJPEG", - description="Video format for camera capture (e.g., MJPEG, YUY2). " + description="Video format for camera capture. " "Note: Output video encoding is handled by VideoWriter.", ) codec: Codec = Field( From 308f93d63e7265e9b13cc96e922fe40d6ca68a19 Mon Sep 17 00:00:00 2001 From: t-sasatani Date: Sat, 28 Feb 2026 15:30:36 +0900 Subject: [PATCH 17/31] remove redundant if statement, and variables --- mio/behavior_cam.py | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/mio/behavior_cam.py b/mio/behavior_cam.py index dc566842..70208234 100644 --- a/mio/behavior_cam.py +++ b/mio/behavior_cam.py @@ -177,7 +177,6 @@ def capture( self.logger.info("Press Ctrl+C to stop recording") frames_written = 0 - frame_index = 0 first_frame = True start_time = None last_fps_log_time = None @@ -186,15 +185,6 @@ def capture( try: for frame_data in exact_iter(frame_queue.get, None): - if frame_data is None: - # Early termination signal from camera process (camera failed) - if frames_written == 0: - raise RuntimeError( - "Camera failed to initialize or read frames. " - "Please check camera connection and settings." - ) - break - frame, unix_time = frame_data # Get actual dimensions from first frame and initialize timing @@ -218,13 +208,12 @@ def capture( # Write frame metadata to CSV csv_writer.append( { - "frame_index": frame_index, + "frame_index": frames_written, "unix_time": unix_time, } ) frames_written += 1 - frame_index += 1 frames_in_window += 1 # Log FPS at regular intervals (FPS for the last window) From ece770acb5f1c920a09cc938579fb37b84ac442a Mon Sep 17 00:00:00 2001 From: t-sasatani Date: Sat, 28 Feb 2026 15:33:12 +0900 Subject: [PATCH 18/31] unify path setting route, handle no video situation, better var naming --- mio/behavior_cam.py | 11 ++++++++--- mio/cli/usbcam.py | 2 +- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/mio/behavior_cam.py b/mio/behavior_cam.py index 70208234..521049c4 100644 --- a/mio/behavior_cam.py +++ b/mio/behavior_cam.py @@ -220,10 +220,10 @@ def capture( current_time = time.time() window_elapsed = current_time - last_fps_log_time if window_elapsed >= FPS_LOG_INTERVAL_SECONDS: - fps = frames_in_window / window_elapsed + measured_fps = frames_in_window / window_elapsed total_elapsed = current_time - start_time self.logger.info( - f"\nFPS:\t{fps:.2f}\nFrames:\t{frames_written} \n" + f"\nFPS:\t{measured_fps:.2f}\nFrames:\t{frames_written} \n" f"Time:\t{total_elapsed:.1f}s \n" ) last_fps_log_time = current_time @@ -269,4 +269,9 @@ def capture( cv2.destroyAllWindows() cv2.waitKey(100) - self.logger.info(f"Saved recording to {video_path} ({frames_written} frames written)") + if writer_used: + self.logger.info( + f"Saved recording to {video_path} ({frames_written} frames written)" + ) + else: + self.logger.warning("No frames were written, recording file removed") diff --git a/mio/cli/usbcam.py b/mio/cli/usbcam.py index 23f5c79a..abf9a7ec 100644 --- a/mio/cli/usbcam.py +++ b/mio/cli/usbcam.py @@ -101,7 +101,7 @@ def record(config: str, output_dir: Optional[str], index: Optional[int]) -> None behavior_cam = BehaviorCam(recording_config=recording_config, camera_index=camera_index) try: - behavior_cam.capture(output_dir=output_dir) + behavior_cam.capture() except Exception as e: click.echo(f"Error recording video: {e}", err=True) raise click.ClickException(f"Error recording video: {e}") from e From 88d11d6441fc9a67f4c45c0e607890c0bd4d2aba Mon Sep 17 00:00:00 2001 From: t-sasatani Date: Sat, 28 Feb 2026 16:34:56 +0900 Subject: [PATCH 19/31] mock testing for usbcam, large test for finding os dependent bugs --- mio/behavior_cam.py | 26 ++++++- mio/cli/usbcam.py | 54 ++++++++++++- mio/devices/mocks.py | 77 +++++++++++++++++- mio/devices/usbcam.py | 27 ++++++- tests/conftest.py | 19 +++++ tests/test_behavior_cam.py | 155 +++++++++++++++++++++++++++++++++++++ 6 files changed, 350 insertions(+), 8 deletions(-) create mode 100644 tests/test_behavior_cam.py diff --git a/mio/behavior_cam.py b/mio/behavior_cam.py index 521049c4..dfc14012 100644 --- a/mio/behavior_cam.py +++ b/mio/behavior_cam.py @@ -10,9 +10,11 @@ from typing import Optional, Union import cv2 +import numpy as np from mio import init_logger from mio.devices.usbcam import convert_frame_for_codec, open_camera +from mio.exceptions import EndOfRecordingException from mio.io import BufferedCSVWriter, VideoWriter from mio.models.usbcam import USBCameraRecordingConfig from mio.types import ConfigSource @@ -79,7 +81,11 @@ def _camera_recv( try: while not self.terminate.is_set(): - ret, frame = cap.read() + try: + ret, frame = cap.read() + except EndOfRecordingException: + locallogs.info("End of recorded data") + break if ret: # Get timestamp for this frame (float unix time in seconds) unix_time = time.time() @@ -106,6 +112,7 @@ def capture( self, output_dir: Optional[str] = None, show_video: bool = True, + capture_binary: Optional[Path] = None, ) -> None: """ Start frame capture and recording. @@ -113,6 +120,7 @@ def capture( Args: output_dir: Output directory (defaults to config.output_dir) show_video: If True, display video preview window + capture_binary: If set, save raw frames and timestamps to this ``.npz`` path """ self.terminate.clear() @@ -182,6 +190,8 @@ def capture( last_fps_log_time = None frames_in_window = 0 writer_used = False + binary_frames = [] if capture_binary else None + binary_timestamps = [] if capture_binary else None try: for frame_data in exact_iter(frame_queue.get, None): @@ -213,6 +223,10 @@ def capture( } ) + if capture_binary: + binary_frames.append(frame) + binary_timestamps.append(unix_time) + frames_written += 1 frames_in_window += 1 @@ -265,6 +279,16 @@ def capture( video_path.unlink() csv_writer.close() + if capture_binary and binary_frames: + np.savez( + capture_binary, + frames=np.array(binary_frames, dtype=np.uint8), + timestamps=np.array(binary_timestamps, dtype=np.float64), + ) + self.logger.info( + f"Saved binary data to {capture_binary} ({len(binary_frames)} frames)" + ) + if show_video: cv2.destroyAllWindows() cv2.waitKey(100) diff --git a/mio/cli/usbcam.py b/mio/cli/usbcam.py index abf9a7ec..f9863fc2 100644 --- a/mio/cli/usbcam.py +++ b/mio/cli/usbcam.py @@ -2,6 +2,8 @@ CLI commands for recording video from USB camera. """ +import os +from pathlib import Path from typing import Optional import click @@ -66,7 +68,15 @@ def usbcam(ctx: click.Context, list_cameras: bool) -> None: type=int, help="Specify camera index (optional)", ) -def record(config: str, output_dir: Optional[str], index: Optional[int]) -> None: +@click.option( + "-b", + "--binary_export", + is_flag=True, + help="Save raw frames to a .npz file alongside the video", +) +def record( + config: str, output_dir: Optional[str], index: Optional[int], binary_export: bool +) -> None: """Record video with Unix timestamp filename""" recording_config = USBCameraRecordingConfig.from_any(config) @@ -99,9 +109,49 @@ def record(config: str, output_dir: Optional[str], index: Optional[int]) -> None ) camera_index = int(selected_index) + # Compute binary export path if requested + if binary_export: + import time as _time + + binary_output = Path(recording_config.output_dir) / f"{int(_time.time())}.npz" + else: + binary_output = None + behavior_cam = BehaviorCam(recording_config=recording_config, camera_index=camera_index) try: - behavior_cam.capture() + behavior_cam.capture(capture_binary=binary_output) except Exception as e: click.echo(f"Error recording video: {e}", err=True) raise click.ClickException(f"Error recording video: {e}") from e + + +@usbcam.command() +@click.option( + "-c", + "--config", + required=True, + type=ConfigIDOrPath(), + help="Either a config `id` or a path to USB camera config YAML file.", +) +@click.option( + "-s", + "--source", + required=True, + help="Path to .npz file with recorded frames", + type=click.Path(exists=True), +) +@click.option( + "-b", + "--binary_export", + is_flag=True, + help="Save raw frames to a .npz file alongside the video", +) +@click.pass_context +def test(ctx: click.Context, config: str, source: str, binary_export: bool) -> None: + """ + Run BehaviorCam in testing mode, using USBCamMock rather than the actual device. + """ + os.environ["BEHAVIORCAM_MOCKRUN"] = "just_placeholder" + os.environ["PYTEST_USBCAM_DATA_FILE"] = str(source) + + ctx.invoke(record, config=config, output_dir=None, index=0, binary_export=binary_export) diff --git a/mio/devices/mocks.py b/mio/devices/mocks.py index 3cdcb96d..757b42c8 100644 --- a/mio/devices/mocks.py +++ b/mio/devices/mocks.py @@ -11,14 +11,17 @@ import os import sys +import time from pathlib import Path -from typing import Dict, Optional +from typing import Dict, Optional, Tuple if sys.version_info < (3, 11): from typing_extensions import Self else: from typing import Self +import numpy as np + from mio.exceptions import EndOfRecordingException @@ -97,3 +100,75 @@ def __next__(self) -> bytes: return self.read_data() except EndOfRecordingException as e: raise StopIteration() from e + + +class USBCamMock: + """ + Mock class for :class:`cv2.VideoCapture`. + + Replays frames from a ``.npz`` file recorded with ``mio usbcam record --binary_export`` + with keys ``frames`` (N, H, W, C) uint8 and ``timestamps`` (N,) float64. + + Set as class variable so that it can be monkeypatched in tests that + require different source data files. + + Can be set using the ``PYTEST_USBCAM_DATA_FILE`` environment variable if + this mock is to be used within a separate process. + """ + + DATA_FILE: Optional[Path] = None + REALTIME: bool = False + """If True, sleep between frames to match recorded timestamps. + + Can also be set via ``PYTEST_USBCAM_REALTIME=1`` env var for multiprocessing. + """ + + def __init__(self) -> None: + if self.DATA_FILE is None: + if os.environ.get("PYTEST_USBCAM_DATA_FILE") is not None: + # need to get file from env variables here because on some platforms + # the default method for creating a new process is "spawn" which creates + # an entirely new python session instead of "fork" which would preserve + # the classvar + data_file: str = os.environ.get("PYTEST_USBCAM_DATA_FILE") # type: ignore + self.DATA_FILE = Path(data_file) + USBCamMock.DATA_FILE = Path(data_file) + else: + raise RuntimeError("DATA_FILE class attr must be set before using USBCamMock") + + if not self.REALTIME and os.environ.get("PYTEST_USBCAM_REALTIME"): + self.REALTIME = True # Not sure why this is needed but following the OKDevMock for now + USBCamMock.REALTIME = True + + data = np.load(self.DATA_FILE) + self._frames = data["frames"] + self._timestamps = data["timestamps"] + self._position = 0 + self._opened = True + self._props: Dict[int, float] = {} + + def isOpened(self) -> bool: # noqa: N802 - match cv2.VideoCapture API + return self._opened + + def read(self) -> Tuple[bool, Optional[np.ndarray]]: + if self._position >= len(self._frames): + raise EndOfRecordingException("End of recorded frames") + + if self.REALTIME and self._position > 0: + dt = self._timestamps[self._position] - self._timestamps[self._position - 1] + if dt > 0: + time.sleep(dt) + + frame = self._frames[self._position] + self._position += 1 + return True, frame + + def set(self, prop: int, value: float) -> bool: + self._props[prop] = value + return True + + def get(self, prop: int) -> float: + return self._props.get(prop, 0.0) + + def release(self) -> None: + self._opened = False diff --git a/mio/devices/usbcam.py b/mio/devices/usbcam.py index 3a6ff6af..55b4e297 100644 --- a/mio/devices/usbcam.py +++ b/mio/devices/usbcam.py @@ -2,8 +2,9 @@ USB Camera device helper functions. """ +import os import time -from typing import Dict, TypedDict +from typing import TYPE_CHECKING, Dict, TypedDict, Union import cv2 import numpy as np @@ -11,6 +12,9 @@ from mio.logging import init_logger from mio.models.usbcam import Codec +if TYPE_CHECKING: + from mio.devices.mocks import USBCamMock + logger = init_logger("usbcam") # Constants @@ -18,6 +22,12 @@ CAMERA_INIT_DELAY_SECONDS = 0.1 # Delay after setting camera properties before reading CAMERA_INIT_RETRY_ATTEMPTS = 3 # Number of retry attempts when reading initial frame +# Mapping from capture format names to FourCC codes (FourCC requires exactly 4 characters) +CAPTURE_FORMAT_FOURCC: Dict[str, str] = { + "MJPEG": "MJPG", + "YUY2": "YUY2", +} + class CameraInfo(TypedDict): """Camera information from OpenCV discovery.""" @@ -61,10 +71,13 @@ def open_camera( frame_height: int, fps: int, capture_format: str = "MJPEG", -) -> cv2.VideoCapture: +) -> Union[cv2.VideoCapture, "USBCamMock"]: """ Open and configure a camera with the specified settings. + In test/mock mode (``PYTEST_CURRENT_TEST`` or ``BEHAVIORCAM_MOCKRUN`` env var set), + returns a :class:`~mio.devices.mocks.USBCamMock` instead of a real camera. + Args: camera_index: Index of the camera to open frame_width: Desired frame width @@ -73,16 +86,22 @@ def open_camera( capture_format: Camera capture format (e.g., "MJPEG", "YUY2") Returns: - Configured VideoCapture object + Configured VideoCapture object (or USBCamMock in test mode) Raises: RuntimeError: If camera cannot be opened or cannot read frames """ + if os.environ.get("PYTEST_CURRENT_TEST") or os.environ.get("BEHAVIORCAM_MOCKRUN"): + from mio.devices.mocks import USBCamMock + + return USBCamMock() + cap = cv2.VideoCapture(camera_index) if not cap.isOpened(): raise RuntimeError(f"Failed to open camera at index {camera_index}") - fourcc = cv2.VideoWriter_fourcc(*capture_format) + fourcc_code = CAPTURE_FORMAT_FOURCC[capture_format] + fourcc = cv2.VideoWriter_fourcc(*fourcc_code) cap.set(cv2.CAP_PROP_FOURCC, fourcc) cap.set(cv2.CAP_PROP_FRAME_WIDTH, frame_width) cap.set(cv2.CAP_PROP_FRAME_HEIGHT, frame_height) diff --git a/tests/conftest.py b/tests/conftest.py index b5430eb3..a86703a1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -63,6 +63,25 @@ def _set_okdev_input(file: Union[str, Path]): return _set_okdev_input +@pytest.fixture() +def set_usbcam_input(monkeypatch): + """ + closure fixture to set the environment variable used by BehaviorCam to set the + USBCamMock data source + """ + + def _set_usbcam_input(file: Union[str, Path], realtime: bool = False): + from mio.devices.mocks import USBCamMock + + monkeypatch.setattr(USBCamMock, "DATA_FILE", file) + monkeypatch.setattr(USBCamMock, "REALTIME", realtime) + os.environ["PYTEST_USBCAM_DATA_FILE"] = str(file) + if realtime: + os.environ["PYTEST_USBCAM_REALTIME"] = "1" + + return _set_usbcam_input + + @pytest.fixture() def config_override(tmp_path) -> Callable[[Path, dict], Path]: """ diff --git a/tests/test_behavior_cam.py b/tests/test_behavior_cam.py new file mode 100644 index 00000000..94d1299d --- /dev/null +++ b/tests/test_behavior_cam.py @@ -0,0 +1,155 @@ +import csv +from pathlib import Path + +import numpy as np +import pytest + +from mio.behavior_cam import BehaviorCam +from mio.io import VideoReader +from mio.models.usbcam import USBCameraRecordingConfig + +NUM_TEST_FRAMES = 10 +TEST_WIDTH = 1280 +TEST_HEIGHT = 720 +TEST_FPS = 20 + + +def _make_npz(path: Path, num_frames: int = NUM_TEST_FRAMES) -> Path: + """Generate a synthetic .npz matching elp-camera config.""" + frames = np.random.default_rng().integers( + 0, 255, size=(num_frames, TEST_HEIGHT, TEST_WIDTH, 3), dtype=np.uint8 + ) + timestamps = np.arange(num_frames, dtype=np.float64) / TEST_FPS + np.savez(path, frames=frames, timestamps=timestamps) + return path + + +def test_capture_with_mock(set_usbcam_input, tmp_path): + """Test that BehaviorCam.capture() produces video and CSV output using mock data.""" + config = USBCameraRecordingConfig.from_id("elp-camera") + config.output_dir = str(tmp_path) + config.ntp_server = None + + npz_path = _make_npz(tmp_path / "test_input.npz") + set_usbcam_input(npz_path) + + behavior_cam = BehaviorCam(recording_config=config, camera_index=0) + behavior_cam.capture(show_video=False) + + video_files = list(tmp_path.glob("*.mp4")) + list(tmp_path.glob("*.avi")) + csv_files = list(tmp_path.glob("*.csv")) + + assert len(video_files) == 1, f"Expected 1 video file, found {len(video_files)}" + assert len(csv_files) == 1, f"Expected 1 CSV file, found {len(csv_files)}" + + with open(csv_files[0]) as f: + reader = csv.DictReader(f) + rows = list(reader) + + assert "frame_index" in reader.fieldnames + assert "unix_time" in reader.fieldnames + assert len(rows) == NUM_TEST_FRAMES, f"Expected {NUM_TEST_FRAMES} rows, got {len(rows)}" + + +def test_capture_binary_export(set_usbcam_input, tmp_path): + """Test that capture_binary saves raw frames to .npz.""" + config = USBCameraRecordingConfig.from_id("elp-camera") + config.output_dir = str(tmp_path) + config.ntp_server = None + + npz_path = _make_npz(tmp_path / "test_input.npz") + set_usbcam_input(npz_path) + + binary_output = tmp_path / "export.npz" + behavior_cam = BehaviorCam(recording_config=config, camera_index=0) + behavior_cam.capture(show_video=False, capture_binary=binary_output) + + assert binary_output.exists() + + data = np.load(binary_output) + assert "frames" in data + assert "timestamps" in data + assert data["frames"].shape[0] == NUM_TEST_FRAMES + assert data["timestamps"].shape[0] == NUM_TEST_FRAMES + + +STRESS_NUM_FRAMES = 1000 + + +@pytest.fixture(params=["cv2", "skvideo"]) +def backend_config(request, tmp_path) -> USBCameraRecordingConfig: + """ELP camera config with parameterized backend for comparison testing.""" + config = USBCameraRecordingConfig.from_id("elp-camera") + config.output_dir = str(tmp_path) + config.ntp_server = None + config.backend = request.param + if request.param == "skvideo": + config.codec = "libx264" + config.pix_fmt = "yuv420p" + return config + + +@pytest.mark.xfail(reason="skvideo backend drops frames — CSV count != video frame count", strict=False) +def test_frame_count_matches_csv(backend_config, set_usbcam_input, tmp_path): + """Verify video frame count matches CSV row count for each backend (1000 frames). + + Known bug: skvideo backend may produce fewer video frames than CSV rows. + """ + npz_path = _make_npz(tmp_path / "stress_input.npz", num_frames=STRESS_NUM_FRAMES) + set_usbcam_input(npz_path) + + behavior_cam = BehaviorCam(recording_config=backend_config, camera_index=0) + behavior_cam.capture(show_video=False) + + video_files = list(tmp_path.glob("*.mp4")) + list(tmp_path.glob("*.avi")) + csv_files = list(tmp_path.glob("*.csv")) + + assert len(video_files) == 1, f"Expected 1 video file, found {len(video_files)}" + assert len(csv_files) == 1, f"Expected 1 CSV file, found {len(csv_files)}" + + with open(csv_files[0]) as f: + csv_row_count = sum(1 for _ in csv.DictReader(f)) + + reader = VideoReader(str(video_files[0])) + video_frame_count = sum(1 for _ in reader.read_frames()) + reader.release() + + assert video_frame_count == csv_row_count, ( + f"Frame count mismatch ({backend_config.backend} backend): " + f"video has {video_frame_count} frames but CSV has {csv_row_count} rows" + ) + + +REALTIME_NUM_FRAMES = 1000 + + +@pytest.mark.xfail(reason="skvideo backend drops frames — CSV count != video frame count", strict=False) +def test_frame_count_realtime(backend_config, set_usbcam_input, tmp_path): + """Verify frame count with realtime replay to simulate real camera timing. + + Known bug: skvideo backend may produce fewer video frames than CSV rows + under real-time write pressure. + """ + npz_path = _make_npz(tmp_path / "realtime_input.npz", num_frames=REALTIME_NUM_FRAMES) + set_usbcam_input(npz_path, realtime=True) + + behavior_cam = BehaviorCam(recording_config=backend_config, camera_index=0) + behavior_cam.capture(show_video=False) + + video_files = list(tmp_path.glob("*.mp4")) + list(tmp_path.glob("*.avi")) + csv_files = list(tmp_path.glob("*.csv")) + + assert len(video_files) == 1, f"Expected 1 video file, found {len(video_files)}" + assert len(csv_files) == 1, f"Expected 1 CSV file, found {len(csv_files)}" + + with open(csv_files[0]) as f: + csv_row_count = sum(1 for _ in csv.DictReader(f)) + + reader = VideoReader(str(video_files[0])) + video_frame_count = sum(1 for _ in reader.read_frames()) + reader.release() + + assert video_frame_count == csv_row_count, ( + f"Frame count mismatch ({backend_config.backend} backend, realtime): " + f"video has {video_frame_count} frames but CSV has {csv_row_count} rows" + ) From 21b527fb87b7cd30deccbff22752ee95ef0c339c Mon Sep 17 00:00:00 2001 From: t-sasatani Date: Sat, 28 Feb 2026 16:49:55 +0900 Subject: [PATCH 20/31] proper test sizing --- tests/test_behavior_cam.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/tests/test_behavior_cam.py b/tests/test_behavior_cam.py index 94d1299d..d8b33542 100644 --- a/tests/test_behavior_cam.py +++ b/tests/test_behavior_cam.py @@ -73,7 +73,7 @@ def test_capture_binary_export(set_usbcam_input, tmp_path): assert data["timestamps"].shape[0] == NUM_TEST_FRAMES -STRESS_NUM_FRAMES = 1000 +STRESS_NUM_FRAMES = 100 @pytest.fixture(params=["cv2", "skvideo"]) @@ -91,7 +91,7 @@ def backend_config(request, tmp_path) -> USBCameraRecordingConfig: @pytest.mark.xfail(reason="skvideo backend drops frames — CSV count != video frame count", strict=False) def test_frame_count_matches_csv(backend_config, set_usbcam_input, tmp_path): - """Verify video frame count matches CSV row count for each backend (1000 frames). + """Verify video frame count matches CSV row count for each backend. Known bug: skvideo backend may produce fewer video frames than CSV rows. """ @@ -120,9 +120,6 @@ def test_frame_count_matches_csv(backend_config, set_usbcam_input, tmp_path): ) -REALTIME_NUM_FRAMES = 1000 - - @pytest.mark.xfail(reason="skvideo backend drops frames — CSV count != video frame count", strict=False) def test_frame_count_realtime(backend_config, set_usbcam_input, tmp_path): """Verify frame count with realtime replay to simulate real camera timing. @@ -130,7 +127,8 @@ def test_frame_count_realtime(backend_config, set_usbcam_input, tmp_path): Known bug: skvideo backend may produce fewer video frames than CSV rows under real-time write pressure. """ - npz_path = _make_npz(tmp_path / "realtime_input.npz", num_frames=REALTIME_NUM_FRAMES) + num_frames = STRESS_NUM_FRAMES + npz_path = _make_npz(tmp_path / "realtime_input.npz", num_frames=num_frames) set_usbcam_input(npz_path, realtime=True) behavior_cam = BehaviorCam(recording_config=backend_config, camera_index=0) From c04db509672539f1eaf6410b4fdeebd7a6f29ca2 Mon Sep 17 00:00:00 2001 From: t-sasatani Date: Sat, 28 Feb 2026 17:00:50 +0900 Subject: [PATCH 21/31] vary input fps --- tests/test_behavior_cam.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/tests/test_behavior_cam.py b/tests/test_behavior_cam.py index d8b33542..c4f8b230 100644 --- a/tests/test_behavior_cam.py +++ b/tests/test_behavior_cam.py @@ -14,12 +14,12 @@ TEST_FPS = 20 -def _make_npz(path: Path, num_frames: int = NUM_TEST_FRAMES) -> Path: +def _make_npz(path: Path, num_frames: int = NUM_TEST_FRAMES, fps: float = TEST_FPS) -> Path: """Generate a synthetic .npz matching elp-camera config.""" frames = np.random.default_rng().integers( 0, 255, size=(num_frames, TEST_HEIGHT, TEST_WIDTH, 3), dtype=np.uint8 ) - timestamps = np.arange(num_frames, dtype=np.float64) / TEST_FPS + timestamps = np.arange(num_frames, dtype=np.float64) / fps np.savez(path, frames=frames, timestamps=timestamps) return path @@ -121,14 +121,14 @@ def test_frame_count_matches_csv(backend_config, set_usbcam_input, tmp_path): @pytest.mark.xfail(reason="skvideo backend drops frames — CSV count != video frame count", strict=False) -def test_frame_count_realtime(backend_config, set_usbcam_input, tmp_path): +@pytest.mark.parametrize("input_fps", [20, 19, 18, 15]) +def test_frame_count_realtime(backend_config, set_usbcam_input, tmp_path, input_fps): """Verify frame count with realtime replay to simulate real camera timing. - Known bug: skvideo backend may produce fewer video frames than CSV rows - under real-time write pressure. + Real cameras often deliver slightly fewer FPS than configured. + Known bug: skvideo backend may produce fewer video frames than CSV rows. """ - num_frames = STRESS_NUM_FRAMES - npz_path = _make_npz(tmp_path / "realtime_input.npz", num_frames=num_frames) + npz_path = _make_npz(tmp_path / "realtime_input.npz", num_frames=STRESS_NUM_FRAMES, fps=input_fps) set_usbcam_input(npz_path, realtime=True) behavior_cam = BehaviorCam(recording_config=backend_config, camera_index=0) @@ -148,6 +148,7 @@ def test_frame_count_realtime(backend_config, set_usbcam_input, tmp_path): reader.release() assert video_frame_count == csv_row_count, ( - f"Frame count mismatch ({backend_config.backend} backend, realtime): " + f"Frame count mismatch ({backend_config.backend} backend, " + f"input {input_fps}fps vs configured {TEST_FPS}fps): " f"video has {video_frame_count} frames but CSV has {csv_row_count} rows" ) From c60f5281a2bd784756f5617e48582ada36350fee Mon Sep 17 00:00:00 2001 From: t-sasatani Date: Sat, 28 Feb 2026 17:07:02 +0900 Subject: [PATCH 22/31] add cli test for usbcam --- tests/test_cli.py | 47 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/tests/test_cli.py b/tests/test_cli.py index b7e15d7e..07eb4b34 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,10 +1,13 @@ +import csv import sys +import numpy as np import pytest from click.testing import CliRunner from mio.cli.config import config, _list from mio.cli.stream import capture +from mio.cli.usbcam import test as usbcam_test from mio import Config from mio.utils import hash_video from mio.models import config as _config_mod @@ -171,3 +174,47 @@ def test_cli_capture( assert result.exit_code == 0 output_hash = hash_video(path_stem.with_suffix(".avi")) assert output_hash == video_hash + + +@pytest.mark.timeout(30) +def test_cli_usbcam_test(set_usbcam_input, tmp_path, config_override): + """ + `mio usbcam test` should run BehaviorCam with mock data and produce video + CSV output. + """ + num_frames = 10 + width, height, fps = 1280, 720, 20 + + frames = np.random.default_rng(42).integers( + 0, 255, size=(num_frames, height, width, 3), dtype=np.uint8 + ) + timestamps = np.arange(num_frames, dtype=np.float64) / fps + npz_path = tmp_path / "test_input.npz" + np.savez(npz_path, frames=frames, timestamps=timestamps) + + set_usbcam_input(npz_path) + + # Override config to write output to tmp_path and disable NTP + from mio import BASE_DIR + + elp_config_path = BASE_DIR / "data" / "config" / "camera" / "elp-camera.yaml" + config_path = config_override( + elp_config_path, {"output_dir": str(tmp_path), "ntp_server": None} + ) + + runner = CliRunner() + result = runner.invoke( + usbcam_test, + ["--config", str(config_path), "--source", str(npz_path)], + ) + + assert result.exit_code == 0, f"CLI failed: {result.output}\n{result.exception}" + + video_files = list(tmp_path.glob("*.mp4")) + list(tmp_path.glob("*.avi")) + csv_files = list(tmp_path.glob("*.csv")) + + assert len(video_files) == 1, f"Expected 1 video file, found {len(video_files)}" + assert len(csv_files) == 1, f"Expected 1 CSV file, found {len(csv_files)}" + + with open(csv_files[0]) as f: + csv_row_count = sum(1 for _ in csv.DictReader(f)) + assert csv_row_count == num_frames From f93bc2b0edc064c27883ea1df22a3ceaeee739ca Mon Sep 17 00:00:00 2001 From: t-sasatani Date: Sat, 28 Feb 2026 17:13:26 +0900 Subject: [PATCH 23/31] make tests headless --- mio/cli/usbcam.py | 23 +++++++++++++++++++---- tests/test_cli.py | 2 +- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/mio/cli/usbcam.py b/mio/cli/usbcam.py index f9863fc2..b08d7635 100644 --- a/mio/cli/usbcam.py +++ b/mio/cli/usbcam.py @@ -74,8 +74,13 @@ def usbcam(ctx: click.Context, list_cameras: bool) -> None: is_flag=True, help="Save raw frames to a .npz file alongside the video", ) +@click.option("--no-display", is_flag=True, help="Don't show video preview in real time") def record( - config: str, output_dir: Optional[str], index: Optional[int], binary_export: bool + config: str, + output_dir: Optional[str], + index: Optional[int], + binary_export: bool, + no_display: bool, ) -> None: """Record video with Unix timestamp filename""" recording_config = USBCameraRecordingConfig.from_any(config) @@ -119,7 +124,7 @@ def record( behavior_cam = BehaviorCam(recording_config=recording_config, camera_index=camera_index) try: - behavior_cam.capture(capture_binary=binary_output) + behavior_cam.capture(show_video=not no_display, capture_binary=binary_output) except Exception as e: click.echo(f"Error recording video: {e}", err=True) raise click.ClickException(f"Error recording video: {e}") from e @@ -146,12 +151,22 @@ def record( is_flag=True, help="Save raw frames to a .npz file alongside the video", ) +@click.option("--no-display", is_flag=True, help="Don't show video preview in real time") @click.pass_context -def test(ctx: click.Context, config: str, source: str, binary_export: bool) -> None: +def test( + ctx: click.Context, config: str, source: str, binary_export: bool, no_display: bool +) -> None: """ Run BehaviorCam in testing mode, using USBCamMock rather than the actual device. """ os.environ["BEHAVIORCAM_MOCKRUN"] = "just_placeholder" os.environ["PYTEST_USBCAM_DATA_FILE"] = str(source) - ctx.invoke(record, config=config, output_dir=None, index=0, binary_export=binary_export) + ctx.invoke( + record, + config=config, + output_dir=None, + index=0, + binary_export=binary_export, + no_display=no_display, + ) diff --git a/tests/test_cli.py b/tests/test_cli.py index 07eb4b34..17e24646 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -204,7 +204,7 @@ def test_cli_usbcam_test(set_usbcam_input, tmp_path, config_override): runner = CliRunner() result = runner.invoke( usbcam_test, - ["--config", str(config_path), "--source", str(npz_path)], + ["--config", str(config_path), "--source", str(npz_path), "--no-display"], ) assert result.exit_code == 0, f"CLI failed: {result.output}\n{result.exception}" From 0da84ddaa04793ee154c1c0315aa8346cf21d5d6 Mon Sep 17 00:00:00 2001 From: t-sasatani Date: Sat, 28 Feb 2026 17:58:22 +0900 Subject: [PATCH 24/31] add frame count check after usb recording --- mio/behavior_cam.py | 11 +++++++++++ mio/data/config/camera/elp-camera-skvideo.yaml | 12 ++++++++++++ 2 files changed, 23 insertions(+) create mode 100644 mio/data/config/camera/elp-camera-skvideo.yaml diff --git a/mio/behavior_cam.py b/mio/behavior_cam.py index dfc14012..84dcae30 100644 --- a/mio/behavior_cam.py +++ b/mio/behavior_cam.py @@ -297,5 +297,16 @@ def capture( self.logger.info( f"Saved recording to {video_path} ({frames_written} frames written)" ) + + # Verify video frame count matches CSV row count + cap = cv2.VideoCapture(str(video_path)) + video_frame_count = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) + cap.release() + + if video_frame_count != frames_written: + self.logger.warning( + f"Frame count mismatch: video has {video_frame_count} frames " + f"but CSV has {frames_written} rows" + ) else: self.logger.warning("No frames were written, recording file removed") diff --git a/mio/data/config/camera/elp-camera-skvideo.yaml b/mio/data/config/camera/elp-camera-skvideo.yaml new file mode 100644 index 00000000..564be638 --- /dev/null +++ b/mio/data/config/camera/elp-camera-skvideo.yaml @@ -0,0 +1,12 @@ +id: elp-camera-skvideo +mio_model: mio.models.usbcam.USBCameraRecordingConfig +mio_version: 0.8.2.dev13+g338e847.d20251030 +output_dir: user_data/recordings +frame_width: 1280 +frame_height: 720 +fps: 20 +codec: libx264 +pix_fmt: yuv420p +ntp_server: mio-ntp.local +ntp_max_offset_seconds: 0.01 +backend: skvideo From c6d13dae992509fadc725fde6868e0b6aba27488 Mon Sep 17 00:00:00 2001 From: t-sasatani Date: Sat, 28 Feb 2026 18:34:10 +0900 Subject: [PATCH 25/31] add interrupt and moov atom test, remove unused xfail --- mio/behavior_cam.py | 19 ++++++---- tests/test_behavior_cam.py | 71 ++++++++++++++++++++++++++++++++++++-- 2 files changed, 80 insertions(+), 10 deletions(-) diff --git a/mio/behavior_cam.py b/mio/behavior_cam.py index 84dcae30..25e97cd3 100644 --- a/mio/behavior_cam.py +++ b/mio/behavior_cam.py @@ -299,14 +299,19 @@ def capture( ) # Verify video frame count matches CSV row count - cap = cv2.VideoCapture(str(video_path)) - video_frame_count = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) - cap.release() - - if video_frame_count != frames_written: + try: + cap = cv2.VideoCapture(str(video_path)) + video_frame_count = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) + cap.release() + + if video_frame_count != frames_written: + self.logger.warning( + f"Frame count mismatch: video has {video_frame_count} frames " + f"but CSV has {frames_written} rows" + ) + except Exception as e: self.logger.warning( - f"Frame count mismatch: video has {video_frame_count} frames " - f"but CSV has {frames_written} rows" + f"Could not verify video frame count ({self.config.backend} backend): {e}" ) else: self.logger.warning("No frames were written, recording file removed") diff --git a/tests/test_behavior_cam.py b/tests/test_behavior_cam.py index c4f8b230..a02e7599 100644 --- a/tests/test_behavior_cam.py +++ b/tests/test_behavior_cam.py @@ -1,11 +1,13 @@ import csv +import threading from pathlib import Path +import cv2 import numpy as np import pytest from mio.behavior_cam import BehaviorCam -from mio.io import VideoReader +from mio.io import VideoReader, VideoWriter from mio.models.usbcam import USBCameraRecordingConfig NUM_TEST_FRAMES = 10 @@ -73,6 +75,71 @@ def test_capture_binary_export(set_usbcam_input, tmp_path): assert data["timestamps"].shape[0] == NUM_TEST_FRAMES +def test_videowriter_close_writes_moov_atom(tmp_path): + """Verify VideoWriter.close() produces a valid mp4 with moov atom. + + Without proper close (stdin.close + wait), FFmpeg may exit before + writing the moov atom, making the file unreadable. + """ + video_path = tmp_path / "test.mp4" + writer = VideoWriter( + path=video_path, + fps=20, + output_dict={"-vcodec": "libx264", "-f": "mp4", "-pix_fmt": "yuv420p", "-vsync": "0"}, + backend="skvideo", + ) + + frames = np.random.default_rng().integers( + 0, 255, size=(NUM_TEST_FRAMES, TEST_HEIGHT, TEST_WIDTH, 3), dtype=np.uint8 + ) + for frame in frames: + writer.write_frame(frame) + + writer.close() + + # If moov atom is missing, VideoCapture will fail to open or report 0 frames + cap = cv2.VideoCapture(str(video_path)) + assert cap.isOpened(), "Failed to open video — moov atom likely missing" + frame_count = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) + cap.release() + assert frame_count == NUM_TEST_FRAMES, ( + f"Expected {NUM_TEST_FRAMES} frames, got {frame_count}" + ) + + +def test_capture_interrupt_produces_valid_output(set_usbcam_input, tmp_path): + """Verify output is valid when capture is interrupted mid-recording.""" + num_frames = 100 + npz_path = _make_npz(tmp_path / "interrupt_input.npz", num_frames=num_frames) + set_usbcam_input(npz_path, realtime=True) + + config = USBCameraRecordingConfig.from_id("elp-camera") + config.output_dir = str(tmp_path / "output") + config.ntp_server = None + + cam = BehaviorCam(recording_config=config, camera_index=0) + + # Interrupt capture after 2 seconds (~40 frames at 20fps) + timer = threading.Timer(2.0, cam.terminate.set) + timer.start() + cam.capture(show_video=False) + + video_files = list(Path(config.output_dir).glob("*.mp4")) + list( + Path(config.output_dir).glob("*.avi") + ) + assert len(video_files) == 1 + + # Verify the partial recording is readable (moov atom present) + cap = cv2.VideoCapture(str(video_files[0])) + assert cap.isOpened(), "Interrupted recording not readable — moov atom likely missing" + frame_count = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) + cap.release() + assert frame_count > 0, "No frames in interrupted recording" + assert frame_count < num_frames, ( + f"Expected partial recording but got all {num_frames} frames" + ) + + STRESS_NUM_FRAMES = 100 @@ -89,7 +156,6 @@ def backend_config(request, tmp_path) -> USBCameraRecordingConfig: return config -@pytest.mark.xfail(reason="skvideo backend drops frames — CSV count != video frame count", strict=False) def test_frame_count_matches_csv(backend_config, set_usbcam_input, tmp_path): """Verify video frame count matches CSV row count for each backend. @@ -120,7 +186,6 @@ def test_frame_count_matches_csv(backend_config, set_usbcam_input, tmp_path): ) -@pytest.mark.xfail(reason="skvideo backend drops frames — CSV count != video frame count", strict=False) @pytest.mark.parametrize("input_fps", [20, 19, 18, 15]) def test_frame_count_realtime(backend_config, set_usbcam_input, tmp_path, input_fps): """Verify frame count with realtime replay to simulate real camera timing. From a864fd365f8c3caf8d1c39a8165328ab68d758e1 Mon Sep 17 00:00:00 2001 From: t-sasatani Date: Sat, 28 Feb 2026 18:49:52 +0900 Subject: [PATCH 26/31] adjust timeouts to test interrupts --- tests/test_behavior_cam.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/test_behavior_cam.py b/tests/test_behavior_cam.py index a02e7599..d0b52732 100644 --- a/tests/test_behavior_cam.py +++ b/tests/test_behavior_cam.py @@ -107,9 +107,12 @@ def test_videowriter_close_writes_moov_atom(tmp_path): ) +@pytest.mark.timeout(60) def test_capture_interrupt_produces_valid_output(set_usbcam_input, tmp_path): """Verify output is valid when capture is interrupted mid-recording.""" - num_frames = 100 + # 600 frames at 20fps = 30s of realtime playback. + # Timer fires at 15s, allowing for slow multiprocess startup on Windows CI (~5s). + num_frames = 600 npz_path = _make_npz(tmp_path / "interrupt_input.npz", num_frames=num_frames) set_usbcam_input(npz_path, realtime=True) @@ -119,8 +122,7 @@ def test_capture_interrupt_produces_valid_output(set_usbcam_input, tmp_path): cam = BehaviorCam(recording_config=config, camera_index=0) - # Interrupt capture after 2 seconds (~40 frames at 20fps) - timer = threading.Timer(2.0, cam.terminate.set) + timer = threading.Timer(15.0, cam.terminate.set) timer.start() cam.capture(show_video=False) From d5c28e762bbcf334dc177c0881556265ca211e00 Mon Sep 17 00:00:00 2001 From: t-sasatani Date: Sat, 28 Feb 2026 19:00:43 +0900 Subject: [PATCH 27/31] assert terminate happened --- tests/test_behavior_cam.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/test_behavior_cam.py b/tests/test_behavior_cam.py index d0b52732..dbb30951 100644 --- a/tests/test_behavior_cam.py +++ b/tests/test_behavior_cam.py @@ -131,6 +131,9 @@ def test_capture_interrupt_produces_valid_output(set_usbcam_input, tmp_path): ) assert len(video_files) == 1 + # Confirm the interrupt actually fired + assert cam.terminate.is_set(), "terminate event was never set — timer did not fire" + # Verify the partial recording is readable (moov atom present) cap = cv2.VideoCapture(str(video_files[0])) assert cap.isOpened(), "Interrupted recording not readable — moov atom likely missing" @@ -138,7 +141,8 @@ def test_capture_interrupt_produces_valid_output(set_usbcam_input, tmp_path): cap.release() assert frame_count > 0, "No frames in interrupted recording" assert frame_count < num_frames, ( - f"Expected partial recording but got all {num_frames} frames" + f"Expected partial recording ({num_frames} input frames at 20fps realtime) " + f"but got all {frame_count} frames — interrupt may not have fired in time" ) From 5680111508971dcb1d37c665917d2e907413d264 Mon Sep 17 00:00:00 2001 From: t-sasatani Date: Sat, 28 Feb 2026 19:20:40 +0900 Subject: [PATCH 28/31] emulate keyboard interrupt with subprocess --- tests/test_behavior_cam.py | 63 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/tests/test_behavior_cam.py b/tests/test_behavior_cam.py index dbb30951..304d2e1a 100644 --- a/tests/test_behavior_cam.py +++ b/tests/test_behavior_cam.py @@ -1,5 +1,11 @@ import csv +import os +import signal +import subprocess +import sys +import textwrap import threading +import time from pathlib import Path import cv2 @@ -146,6 +152,63 @@ def test_capture_interrupt_produces_valid_output(set_usbcam_input, tmp_path): ) +@pytest.mark.timeout(60) +@pytest.mark.parametrize("config_id", ["elp-camera", "elp-camera-skvideo"]) +def test_sigint_produces_valid_output(tmp_path, config_id): + """Test that a real signal (SIGINT / CTRL_BREAK) produces a valid video. + + Runs capture in a subprocess and sends it a signal to simulate Ctrl+C. + """ + npz_path = tmp_path / "input.npz" + _make_npz(npz_path, num_frames=600) + output_dir = tmp_path / "output" + + script = textwrap.dedent(f"""\ + import os + os.environ["BEHAVIORCAM_MOCKRUN"] = "1" + os.environ["PYTEST_USBCAM_DATA_FILE"] = {str(npz_path)!r} + os.environ["PYTEST_USBCAM_REALTIME"] = "1" + from mio.behavior_cam import BehaviorCam + from mio.models.usbcam import USBCameraRecordingConfig + config = USBCameraRecordingConfig.from_id("{config_id}") + config.output_dir = {str(output_dir)!r} + config.ntp_server = None + cam = BehaviorCam(recording_config=config, camera_index=0) + cam.capture(show_video=False) + """) + + kwargs: dict = {} + if sys.platform == "win32": + kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP + else: + kwargs["start_new_session"] = True + + proc = subprocess.Popen([sys.executable, "-c", script], **kwargs) + + # Wait for capture to start producing frames + time.sleep(15) + + # Send signal — like real Ctrl+C + if sys.platform == "win32": + proc.send_signal(signal.CTRL_BREAK_EVENT) + else: + os.killpg(os.getpgid(proc.pid), signal.SIGINT) + + proc.wait(timeout=30) + + videos = list(output_dir.glob("*.mp4")) + list(output_dir.glob("*.avi")) + assert len(videos) == 1 + + cap = cv2.VideoCapture(str(videos[0])) + assert cap.isOpened(), "Video not readable after signal — moov atom likely missing" + frame_count = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) + cap.release() + assert frame_count > 0, "No frames in recording after signal" + assert frame_count < 600, ( + f"Expected partial recording but got {frame_count} frames — signal may not have fired in time" + ) + + STRESS_NUM_FRAMES = 100 From eb3d0180213a9391c1bdf00bcc6ad8582488ba51 Mon Sep 17 00:00:00 2001 From: t-sasatani Date: Sat, 28 Feb 2026 19:53:20 +0900 Subject: [PATCH 29/31] add `q` for usbcam exit, remove tests forcing fail by interrupts --- mio/behavior_cam.py | 8 +-- tests/test_behavior_cam.py | 103 ------------------------------------- 2 files changed, 5 insertions(+), 106 deletions(-) diff --git a/mio/behavior_cam.py b/mio/behavior_cam.py index 25e97cd3..e72f4ad8 100644 --- a/mio/behavior_cam.py +++ b/mio/behavior_cam.py @@ -182,7 +182,7 @@ def capture( p_camera.start() self.logger.info(f"Recording to {video_path}") - self.logger.info("Press Ctrl+C to stop recording") + self.logger.info("Press 'q' to stop recording") frames_written = 0 first_frame = True @@ -243,11 +243,13 @@ def capture( last_fps_log_time = current_time frames_in_window = 0 - # Show preview + # Show preview and check for 'q' key if show_video: try: cv2.imshow("Recording", frame) - cv2.waitKey(1) + if cv2.waitKey(1) & 0xFF == ord("q"): + self.logger.info("Recording stopped by user ('q' key)") + break except cv2.error as e: self.logger.exception(f"Error displaying frame: {e}") diff --git a/tests/test_behavior_cam.py b/tests/test_behavior_cam.py index 304d2e1a..f0e6c718 100644 --- a/tests/test_behavior_cam.py +++ b/tests/test_behavior_cam.py @@ -1,11 +1,4 @@ import csv -import os -import signal -import subprocess -import sys -import textwrap -import threading -import time from pathlib import Path import cv2 @@ -113,102 +106,6 @@ def test_videowriter_close_writes_moov_atom(tmp_path): ) -@pytest.mark.timeout(60) -def test_capture_interrupt_produces_valid_output(set_usbcam_input, tmp_path): - """Verify output is valid when capture is interrupted mid-recording.""" - # 600 frames at 20fps = 30s of realtime playback. - # Timer fires at 15s, allowing for slow multiprocess startup on Windows CI (~5s). - num_frames = 600 - npz_path = _make_npz(tmp_path / "interrupt_input.npz", num_frames=num_frames) - set_usbcam_input(npz_path, realtime=True) - - config = USBCameraRecordingConfig.from_id("elp-camera") - config.output_dir = str(tmp_path / "output") - config.ntp_server = None - - cam = BehaviorCam(recording_config=config, camera_index=0) - - timer = threading.Timer(15.0, cam.terminate.set) - timer.start() - cam.capture(show_video=False) - - video_files = list(Path(config.output_dir).glob("*.mp4")) + list( - Path(config.output_dir).glob("*.avi") - ) - assert len(video_files) == 1 - - # Confirm the interrupt actually fired - assert cam.terminate.is_set(), "terminate event was never set — timer did not fire" - - # Verify the partial recording is readable (moov atom present) - cap = cv2.VideoCapture(str(video_files[0])) - assert cap.isOpened(), "Interrupted recording not readable — moov atom likely missing" - frame_count = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) - cap.release() - assert frame_count > 0, "No frames in interrupted recording" - assert frame_count < num_frames, ( - f"Expected partial recording ({num_frames} input frames at 20fps realtime) " - f"but got all {frame_count} frames — interrupt may not have fired in time" - ) - - -@pytest.mark.timeout(60) -@pytest.mark.parametrize("config_id", ["elp-camera", "elp-camera-skvideo"]) -def test_sigint_produces_valid_output(tmp_path, config_id): - """Test that a real signal (SIGINT / CTRL_BREAK) produces a valid video. - - Runs capture in a subprocess and sends it a signal to simulate Ctrl+C. - """ - npz_path = tmp_path / "input.npz" - _make_npz(npz_path, num_frames=600) - output_dir = tmp_path / "output" - - script = textwrap.dedent(f"""\ - import os - os.environ["BEHAVIORCAM_MOCKRUN"] = "1" - os.environ["PYTEST_USBCAM_DATA_FILE"] = {str(npz_path)!r} - os.environ["PYTEST_USBCAM_REALTIME"] = "1" - from mio.behavior_cam import BehaviorCam - from mio.models.usbcam import USBCameraRecordingConfig - config = USBCameraRecordingConfig.from_id("{config_id}") - config.output_dir = {str(output_dir)!r} - config.ntp_server = None - cam = BehaviorCam(recording_config=config, camera_index=0) - cam.capture(show_video=False) - """) - - kwargs: dict = {} - if sys.platform == "win32": - kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP - else: - kwargs["start_new_session"] = True - - proc = subprocess.Popen([sys.executable, "-c", script], **kwargs) - - # Wait for capture to start producing frames - time.sleep(15) - - # Send signal — like real Ctrl+C - if sys.platform == "win32": - proc.send_signal(signal.CTRL_BREAK_EVENT) - else: - os.killpg(os.getpgid(proc.pid), signal.SIGINT) - - proc.wait(timeout=30) - - videos = list(output_dir.glob("*.mp4")) + list(output_dir.glob("*.avi")) - assert len(videos) == 1 - - cap = cv2.VideoCapture(str(videos[0])) - assert cap.isOpened(), "Video not readable after signal — moov atom likely missing" - frame_count = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) - cap.release() - assert frame_count > 0, "No frames in recording after signal" - assert frame_count < 600, ( - f"Expected partial recording but got {frame_count} frames — signal may not have fired in time" - ) - - STRESS_NUM_FRAMES = 100 From a15128ce289bdbbbc601b052c535a2423d9678e2 Mon Sep 17 00:00:00 2001 From: t-sasatani Date: Sat, 28 Feb 2026 22:02:00 +0900 Subject: [PATCH 30/31] some unit tests matching streamdaq pattern --- tests/test_behavior_cam.py | 191 ++++++++++++++++++++++++++++++------- 1 file changed, 154 insertions(+), 37 deletions(-) diff --git a/tests/test_behavior_cam.py b/tests/test_behavior_cam.py index f0e6c718..7c76e931 100644 --- a/tests/test_behavior_cam.py +++ b/tests/test_behavior_cam.py @@ -6,6 +6,7 @@ import pytest from mio.behavior_cam import BehaviorCam +from mio.devices.usbcam import convert_frame_for_codec, format_camera_info from mio.io import VideoReader, VideoWriter from mio.models.usbcam import USBCameraRecordingConfig @@ -13,6 +14,8 @@ TEST_WIDTH = 1280 TEST_HEIGHT = 720 TEST_FPS = 20 +STRESS_NUM_FRAMES = 100 + def _make_npz(path: Path, num_frames: int = NUM_TEST_FRAMES, fps: float = TEST_FPS) -> Path: @@ -25,33 +28,6 @@ def _make_npz(path: Path, num_frames: int = NUM_TEST_FRAMES, fps: float = TEST_F return path -def test_capture_with_mock(set_usbcam_input, tmp_path): - """Test that BehaviorCam.capture() produces video and CSV output using mock data.""" - config = USBCameraRecordingConfig.from_id("elp-camera") - config.output_dir = str(tmp_path) - config.ntp_server = None - - npz_path = _make_npz(tmp_path / "test_input.npz") - set_usbcam_input(npz_path) - - behavior_cam = BehaviorCam(recording_config=config, camera_index=0) - behavior_cam.capture(show_video=False) - - video_files = list(tmp_path.glob("*.mp4")) + list(tmp_path.glob("*.avi")) - csv_files = list(tmp_path.glob("*.csv")) - - assert len(video_files) == 1, f"Expected 1 video file, found {len(video_files)}" - assert len(csv_files) == 1, f"Expected 1 CSV file, found {len(csv_files)}" - - with open(csv_files[0]) as f: - reader = csv.DictReader(f) - rows = list(reader) - - assert "frame_index" in reader.fieldnames - assert "unix_time" in reader.fieldnames - assert len(rows) == NUM_TEST_FRAMES, f"Expected {NUM_TEST_FRAMES} rows, got {len(rows)}" - - def test_capture_binary_export(set_usbcam_input, tmp_path): """Test that capture_binary saves raw frames to .npz.""" config = USBCameraRecordingConfig.from_id("elp-camera") @@ -73,7 +49,6 @@ def test_capture_binary_export(set_usbcam_input, tmp_path): assert data["frames"].shape[0] == NUM_TEST_FRAMES assert data["timestamps"].shape[0] == NUM_TEST_FRAMES - def test_videowriter_close_writes_moov_atom(tmp_path): """Verify VideoWriter.close() produces a valid mp4 with moov atom. @@ -105,9 +80,156 @@ def test_videowriter_close_writes_moov_atom(tmp_path): f"Expected {NUM_TEST_FRAMES} frames, got {frame_count}" ) +def test_format_camera_info(): + info = {"name": "Camera 0", "resolution": "1280x720", "fps": 30} + result = format_camera_info(0, info) + assert "[0]" in result + assert "1280x720" in result + assert "30 fps" in result -STRESS_NUM_FRAMES = 100 +def test_usbcam_mock_read_all_frames(set_usbcam_input, tmp_path): + """USBCamMock should yield all frames then raise EndOfRecordingException.""" + from mio.devices.mocks import USBCamMock + from mio.exceptions import EndOfRecordingException + + npz_path = _make_npz(tmp_path / "mock.npz", num_frames=5) + set_usbcam_input(npz_path) + + mock = USBCamMock() + assert mock.isOpened() + + frames_read = 0 + while True: + try: + ret, frame = mock.read() + assert ret is True + assert frame.shape == (TEST_HEIGHT, TEST_WIDTH, 3) + frames_read += 1 + except EndOfRecordingException: + break + + assert frames_read == 5 + +def test_usbcam_mock_set_get(): + """USBCamMock.set()/get() should store and retrieve properties.""" + from mio.devices.mocks import USBCamMock + + # Need DATA_FILE set; use a minimal npz + import tempfile + + with tempfile.NamedTemporaryFile(suffix=".npz") as f: + np.savez(f.name, frames=np.zeros((1, 2, 2, 3), dtype=np.uint8), timestamps=np.array([0.0])) + USBCamMock.DATA_FILE = Path(f.name) + mock = USBCamMock() + + assert mock.get(cv2.CAP_PROP_FPS) == 0.0 # default + mock.set(cv2.CAP_PROP_FPS, 30.0) + assert mock.get(cv2.CAP_PROP_FPS) == 30.0 + +def test_usbcam_mock_release(set_usbcam_input, tmp_path): + """USBCamMock.release() should mark camera as closed.""" + from mio.devices.mocks import USBCamMock + + npz_path = _make_npz(tmp_path / "mock.npz", num_frames=1) + set_usbcam_input(npz_path) + + mock = USBCamMock() + assert mock.isOpened() + mock.release() + assert not mock.isOpened() + +def test_csv_structure(set_usbcam_input, tmp_path): + """Verify CSV has correct columns and monotonically increasing frame indices.""" + config = USBCameraRecordingConfig.from_id("elp-camera") + config.output_dir = str(tmp_path) + config.ntp_server = None + npz_path = _make_npz(tmp_path / "csv_test.npz", num_frames=20) + set_usbcam_input(npz_path) + + cam = BehaviorCam(recording_config=config, camera_index=0) + cam.capture(show_video=False) + + csv_files = list(tmp_path.glob("*.csv")) + assert len(csv_files) == 1 + + with open(csv_files[0]) as f: + reader = csv.DictReader(f) + rows = list(reader) + + # Correct columns + assert reader.fieldnames == ["frame_index", "unix_time"] + + # Monotonically increasing frame index starting at 0 + indices = [int(r["frame_index"]) for r in rows] + assert indices == list(range(len(rows))) + + # Timestamps should be monotonically non-decreasing + times = [float(r["unix_time"]) for r in rows] + assert all(t1 <= t2 for t1, t2 in zip(times, times[1:])) + +def test_write_frame_count_matches_video(set_usbcam_input, tmp_path, monkeypatch): + """Count write_frame calls and verify they match the video frame count.""" + call_count = {"ok": 0, "failed": 0} + original = VideoWriter.write_frame + + def wrapped(self, frame): + ok = original(self, frame) + if ok: + call_count["ok"] += 1 + else: + call_count["failed"] += 1 + return ok + + monkeypatch.setattr(VideoWriter, "write_frame", wrapped, raising=True) + + config = USBCameraRecordingConfig.from_id("elp-camera") + config.output_dir = str(tmp_path) + config.ntp_server = None + + npz_path = _make_npz(tmp_path / "count_test.npz", num_frames=30) + set_usbcam_input(npz_path) + + cam = BehaviorCam(recording_config=config, camera_index=0) + cam.capture(show_video=False) + + video_files = list(tmp_path.glob("*.mp4")) + list(tmp_path.glob("*.avi")) + assert len(video_files) == 1 + + cap = cv2.VideoCapture(str(video_files[0])) + video_frame_count = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) + cap.release() + + assert call_count["failed"] == 0, f"write_frame had {call_count['failed']} failures" + assert call_count["ok"] == video_frame_count, ( + f"write_frame calls ({call_count['ok']}) != video frames ({video_frame_count})" + ) + +@pytest.mark.parametrize( + "codec,expected_ext", + [("h264", ".mp4"), ("libx264", ".mp4"), ("rawvideo", ".avi")], +) +def test_output_container_matches_codec(set_usbcam_input, tmp_path, codec, expected_ext): + """Verify the correct container format is chosen for each codec.""" + config = USBCameraRecordingConfig.from_id("elp-camera") + config.output_dir = str(tmp_path) + config.ntp_server = None + config.codec = codec + if codec == "libx264": + config.backend = "skvideo" + config.pix_fmt = "yuv420p" + + npz_path = _make_npz(tmp_path / "codec_test.npz", num_frames=5) + set_usbcam_input(npz_path) + + cam = BehaviorCam(recording_config=config, camera_index=0) + cam.capture(show_video=False) + + video_files = list(tmp_path.glob(f"*{expected_ext}")) + assert len(video_files) == 1, ( + f"Expected 1 {expected_ext} file for codec={codec}, " + f"found: {list(tmp_path.glob('*.*'))}" + ) @pytest.fixture(params=["cv2", "skvideo"]) def backend_config(request, tmp_path) -> USBCameraRecordingConfig: @@ -133,7 +255,7 @@ def test_frame_count_matches_csv(backend_config, set_usbcam_input, tmp_path): behavior_cam = BehaviorCam(recording_config=backend_config, camera_index=0) behavior_cam.capture(show_video=False) - video_files = list(tmp_path.glob("*.mp4")) + list(tmp_path.glob("*.avi")) + video_files = list(tmp_path.glob("*.mp4")) csv_files = list(tmp_path.glob("*.csv")) assert len(video_files) == 1, f"Expected 1 video file, found {len(video_files)}" @@ -151,21 +273,16 @@ def test_frame_count_matches_csv(backend_config, set_usbcam_input, tmp_path): f"video has {video_frame_count} frames but CSV has {csv_row_count} rows" ) - @pytest.mark.parametrize("input_fps", [20, 19, 18, 15]) def test_frame_count_realtime(backend_config, set_usbcam_input, tmp_path, input_fps): - """Verify frame count with realtime replay to simulate real camera timing. - - Real cameras often deliver slightly fewer FPS than configured. - Known bug: skvideo backend may produce fewer video frames than CSV rows. - """ + """Verify frame count with realtime replay to simulate camera timing.""" npz_path = _make_npz(tmp_path / "realtime_input.npz", num_frames=STRESS_NUM_FRAMES, fps=input_fps) set_usbcam_input(npz_path, realtime=True) behavior_cam = BehaviorCam(recording_config=backend_config, camera_index=0) behavior_cam.capture(show_video=False) - video_files = list(tmp_path.glob("*.mp4")) + list(tmp_path.glob("*.avi")) + video_files = list(tmp_path.glob("*.mp4")) csv_files = list(tmp_path.glob("*.csv")) assert len(video_files) == 1, f"Expected 1 video file, found {len(video_files)}" From c029e195eaf67f06af8271c82be649493b5ab467 Mon Sep 17 00:00:00 2001 From: t-sasatani Date: Sat, 28 Feb 2026 23:21:56 +0900 Subject: [PATCH 31/31] fix windows file permission issue --- tests/test_behavior_cam.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/tests/test_behavior_cam.py b/tests/test_behavior_cam.py index 7c76e931..8e03c347 100644 --- a/tests/test_behavior_cam.py +++ b/tests/test_behavior_cam.py @@ -110,17 +110,15 @@ def test_usbcam_mock_read_all_frames(set_usbcam_input, tmp_path): assert frames_read == 5 -def test_usbcam_mock_set_get(): +def test_usbcam_mock_set_get(tmp_path): """USBCamMock.set()/get() should store and retrieve properties.""" from mio.devices.mocks import USBCamMock # Need DATA_FILE set; use a minimal npz - import tempfile - - with tempfile.NamedTemporaryFile(suffix=".npz") as f: - np.savez(f.name, frames=np.zeros((1, 2, 2, 3), dtype=np.uint8), timestamps=np.array([0.0])) - USBCamMock.DATA_FILE = Path(f.name) - mock = USBCamMock() + npz_path = tmp_path / "mock_props.npz" + np.savez(npz_path, frames=np.zeros((1, 2, 2, 3), dtype=np.uint8), timestamps=np.array([0.0])) + USBCamMock.DATA_FILE = npz_path + mock = USBCamMock() assert mock.get(cv2.CAP_PROP_FPS) == 0.0 # default mock.set(cv2.CAP_PROP_FPS, 30.0)