From ca7a075712f53be074e540926c301d82f3ad13f1 Mon Sep 17 00:00:00 2001 From: author31 Date: Wed, 18 Mar 2026 15:19:28 +0800 Subject: [PATCH 1/8] add launch target --- .gitignore | 5 ++++- Makefile | 3 +++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 0dffe18..e0d904c 100644 --- a/.gitignore +++ b/.gitignore @@ -222,4 +222,7 @@ video/demos/* # UMI specific ignores datasets/* checkpoints/* -demos/* \ No newline at end of file +demos/* + + +workspace/* diff --git a/Makefile b/Makefile index b6ce79f..50fedd3 100644 --- a/Makefile +++ b/Makefile @@ -15,6 +15,9 @@ run-isort: run-formatter: uv run --extra dev ruff format src tests +launch-jupyterlab: + uv run jupyter lab --notebook-dir=workspace --ip=0.0.0.0 --no-browser + clean: rm -rf .pytest_cache .ruff_cache .venv build dist src/*.egg-info find . -type d -name "__pycache__" -prune -exec rm -rf {} + From 0ade38816eb4b02c43c2e7e30fc5090904e47f51 Mon Sep 17 00:00:00 2001 From: author31 Date: Wed, 18 Mar 2026 15:20:27 +0800 Subject: [PATCH 2/8] beautify print msgsq --- src/opai/application/session.py | 92 ++++++++++++++++++++++++++++++++- src/opai/presentation/facade.py | 81 ++++++++++++++++++++++------- tests/test_facade.py | 61 ++++++++++++++++++++-- 3 files changed, 209 insertions(+), 25 deletions(-) diff --git a/src/opai/application/session.py b/src/opai/application/session.py index 62fbcd0..66ef4b5 100644 --- a/src/opai/application/session.py +++ b/src/opai/application/session.py @@ -1,25 +1,60 @@ from __future__ import annotations from collections.abc import Iterable +from dataclasses import dataclass from pathlib import Path from opai.core.exceptions import OPAIValidationError from opai.domain.context import Context from opai.domain.session import DemoAsset, SessionManifest from opai.infrastructure.context_store import ( + SESSION_MANIFEST_FILENAME, + SESSION_ROOT_DIRNAME, + get_active_context, get_session_directory, list_session_names, load_manifest_for_context, persist_manifest_for_context, + session_root, ) from opai.infrastructure.persistence import ( build_file_tree, copy_demo_assets, copy_mapping_asset, list_relative_paths, + load_session_manifest, ) +@dataclass(frozen=True) +class SessionSummary: + name: str + demo_count: int + has_mapping: bool + file_count: int + is_active: bool + + +@dataclass(frozen=True) +class SessionCatalog: + root_dirname: str + root_path: Path + sessions: tuple[SessionSummary, ...] + + +@dataclass(frozen=True) +class SessionBrowseView: + root_dirname: str + root_path: Path + session_name: str + session_path: Path + demo_count: int + has_mapping: bool + file_count: int + file_paths: tuple[str, ...] + tree_payload: dict[str, dict] + + def add_demos(ctx: Context, video_paths: Iterable[str | Path]) -> tuple[DemoAsset, ...]: source_paths = _normalize_video_paths(video_paths, label="demo") manifest = load_manifest_for_context(ctx) @@ -51,10 +86,29 @@ def add_mapping(ctx: Context, video_path: str | Path) -> MappingAsset: def list_sessions() -> list[str]: - return list_session_names() + return [session.name for session in describe_sessions().sessions] def browse_session(name: str) -> tuple[list[str], dict[str, dict]]: + view = describe_session(name) + return list(view.file_paths), view.tree_payload + + +def describe_sessions() -> SessionCatalog: + active_context = get_active_context() + active_name = active_context.name if active_context is not None else None + summaries = tuple( + _build_session_summary(session_name, active_name=active_name) + for session_name in list_session_names() + ) + return SessionCatalog( + root_dirname=SESSION_ROOT_DIRNAME, + root_path=session_root(), + sessions=summaries, + ) + + +def describe_session(name: str) -> SessionBrowseView: session_name = _normalize_session_name(name) session_directory = get_session_directory(session_name) if not session_directory.exists(): @@ -62,7 +116,22 @@ def browse_session(name: str) -> tuple[list[str], dict[str, dict]]: f"Session '{session_name}' does not exist.", details={"session_name": session_name}, ) - return list_relative_paths(session_directory), build_file_tree(session_directory) + manifest = load_session_manifest( + session_directory / SESSION_MANIFEST_FILENAME, + session_name=session_name, + ) + file_paths = tuple(list_relative_paths(session_directory)) + return SessionBrowseView( + root_dirname=SESSION_ROOT_DIRNAME, + root_path=session_root(), + session_name=session_name, + session_path=session_directory, + demo_count=len(manifest.demos), + has_mapping=manifest.mapping is not None, + file_count=len(file_paths), + file_paths=file_paths, + tree_payload=build_file_tree(session_directory), + ) def _normalize_video_paths( @@ -104,3 +173,22 @@ def _normalize_session_name(name: str) -> str: if not isinstance(name, str) or not name.strip(): raise OPAIValidationError("Session name must be a non-empty string.") return name.strip() + + +def _build_session_summary( + session_name: str, + *, + active_name: str | None, +) -> SessionSummary: + session_directory = get_session_directory(session_name) + manifest = load_session_manifest( + session_directory / SESSION_MANIFEST_FILENAME, + session_name=session_name, + ) + return SessionSummary( + name=session_name, + demo_count=len(manifest.demos), + has_mapping=manifest.mapping is not None, + file_count=len(list_relative_paths(session_directory)), + is_active=session_name == active_name, + ) diff --git a/src/opai/presentation/facade.py b/src/opai/presentation/facade.py index 738338b..c6a441a 100644 --- a/src/opai/presentation/facade.py +++ b/src/opai/presentation/facade.py @@ -76,28 +76,56 @@ def add_mapping(video_path: str | Path) -> MappingAsset: def list_sessions() -> list[str]: - from opai.application.session import list_sessions as list_available_sessions - - return list_available_sessions() + Console, Tree = _load_rich_components() + from opai.application.session import describe_sessions + + catalog = describe_sessions() + console = Console() + tree = Tree( + f"[bold]{catalog.root_dirname}[/] [dim]{catalog.root_path}[/]", + guide_style="dim", + ) + session_names = [session.name for session in catalog.sessions] + if not catalog.sessions: + tree.add("[yellow]No sessions found[/]") + console.print(tree) + return session_names + + for session in catalog.sessions: + tags: list[str] = [] + if session.is_active: + tags.append("current") + tags.append(f"demos={session.demo_count}") + tags.append(f"mapping={'yes' if session.has_mapping else 'no'}") + tags.append(f"files={session.file_count}") + tree.add(f"[bold cyan]{session.name}[/] [dim]({', '.join(tags)})[/]") + console.print(tree) + return session_names def browse_session(name: str) -> list[str]: normalized_name = _normalize_session_name(name) - try: - from rich.console import Console - from rich.tree import Tree - except ModuleNotFoundError as exc: - raise OPAIDependencyError( - "Session browsing requires the 'rich' package. Install project dependencies before calling opai.browse_session(...)." - ) from exc - - from opai.application.session import browse_session as browse_named_session - - file_paths, tree_payload = browse_named_session(normalized_name) - tree = Tree(normalized_name) - _append_tree_nodes(tree, tree_payload) - Console().print(tree) - return file_paths + Console, Tree = _load_rich_components() + from opai.application.session import describe_session + + view = describe_session(normalized_name) + console = Console() + tree = Tree( + f"[bold]{view.root_dirname}[/] [dim]{view.root_path}[/]", + guide_style="dim", + ) + session_branch = tree.add( + "[bold magenta]" + f"{view.session_name}" + "[/] [dim]" + f"(path={view.session_path.name}, demos={view.demo_count}, " + f"mapping={'yes' if view.has_mapping else 'no'}, files={view.file_count})" + "[/]" + ) + session_branch.add(f"[dim]path:[/] [cyan]{view.session_path}[/]") + _append_tree_nodes(session_branch, view.tree_payload) + console.print(tree) + return list(view.file_paths) def main() -> None: @@ -120,6 +148,19 @@ def _normalize_session_name(name: str) -> str: def _append_tree_nodes(tree, payload: dict[str, dict]) -> None: for name, child in payload.items(): - branch = tree.add(name) - if isinstance(child, dict): + is_directory = isinstance(child, dict) and bool(child) + label = f"[bold blue]{name}/[/]" if is_directory else f"[green]{name}[/]" + branch = tree.add(label) + if is_directory: _append_tree_nodes(branch, child) + + +def _load_rich_components(): + try: + from rich.console import Console + from rich.tree import Tree + except ModuleNotFoundError as exc: + raise OPAIDependencyError( + "Session browsing requires the 'rich' package. Install project dependencies before calling opai.list_sessions(...) or opai.browse_session(...)." + ) from exc + return Console, Tree diff --git a/tests/test_facade.py b/tests/test_facade.py index 5b4e800..085d900 100644 --- a/tests/test_facade.py +++ b/tests/test_facade.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import json import sys from builtins import __import__ as builtin_import @@ -165,8 +167,39 @@ def test_list_sessions_returns_names(tmp_path, monkeypatch) -> None: monkeypatch.chdir(tmp_path) opai.init("session-b") opai.init("session-a") + recorder = _install_fake_rich(monkeypatch) assert opai.list_sessions() == ["session-a", "session-b"] + tree = recorder["prints"][0][0] + assert tree.label.startswith("[bold].opai_sessions[/]") + assert [child.label for child in tree.children] == [ + "[bold cyan]session-a[/] [dim](current, demos=0, mapping=no, files=1)[/]", + "[bold cyan]session-b[/] [dim](demos=0, mapping=no, files=1)[/]", + ] + + +def test_list_sessions_shows_empty_state(tmp_path, monkeypatch) -> None: + monkeypatch.chdir(tmp_path) + recorder = _install_fake_rich(monkeypatch) + + assert opai.list_sessions() == [] + tree = recorder["prints"][0][0] + assert tree.label.startswith("[bold].opai_sessions[/]") + assert [child.label for child in tree.children] == ["[yellow]No sessions found[/]"] + + +def test_list_sessions_requires_rich(tmp_path, monkeypatch) -> None: + monkeypatch.chdir(tmp_path) + + def fake_import(name, globals=None, locals=None, fromlist=(), level=0): + if name.startswith("rich"): + raise ModuleNotFoundError("No module named 'rich'") + return builtin_import(name, globals, locals, fromlist, level) + + monkeypatch.setattr("builtins.__import__", fake_import) + + with pytest.raises(OPAIDependencyError, match="rich"): + opai.list_sessions() def test_browse_session_returns_files_without_changing_active_context( @@ -177,12 +210,27 @@ def test_browse_session_returns_files_without_changing_active_context( demo_path = tmp_path / "demo.mp4" demo_path.write_bytes(b"demo") opai.add_demos([demo_path]) - _install_fake_rich(monkeypatch) + recorder = _install_fake_rich(monkeypatch) files = opai.browse_session("session-001") assert "captures/demos/demo-0001/demo.mp4" in files assert opai.get_context().name == ctx.name + tree = recorder["prints"][0][0] + assert tree.label.startswith("[bold].opai_sessions[/]") + session_branch = tree.children[0] + assert session_branch.label == ( + "[bold magenta]session-001[/] " + "[dim](path=session-001, demos=1, mapping=no, files=2)[/]" + ) + assert ( + session_branch.children[0].label + == f"[dim]path:[/] [cyan]{ctx.session_directory}[/]" + ) + assert [child.label for child in session_branch.children[1:]] == [ + "[bold blue]captures/[/]", + "[green]session.json[/]", + ] def test_browse_session_requires_rich(tmp_path, monkeypatch) -> None: @@ -252,10 +300,15 @@ def _build_fake_cv2() -> SimpleNamespace: ) -def _install_fake_rich(monkeypatch: pytest.MonkeyPatch) -> None: +def _install_fake_rich( + monkeypatch: pytest.MonkeyPatch, +) -> dict[str, list[tuple[object, ...]]]: + recorder: dict[str, list[tuple[object, ...]]] = {"prints": []} + class FakeTree: - def __init__(self, label: str) -> None: + def __init__(self, label: str, **kwargs) -> None: self.label = label + self.kwargs = kwargs self.children = [] def add(self, label: str): @@ -265,6 +318,7 @@ def add(self, label: str): class FakeConsole: def print(self, *_args, **_kwargs) -> None: + recorder["prints"].append(_args) return None monkeypatch.setitem(sys.modules, "rich", SimpleNamespace()) @@ -272,3 +326,4 @@ def print(self, *_args, **_kwargs) -> None: sys.modules, "rich.console", SimpleNamespace(Console=FakeConsole) ) monkeypatch.setitem(sys.modules, "rich.tree", SimpleNamespace(Tree=FakeTree)) + return recorder From b5fa74c83d4987bede9299792fd2f344bdd51786 Mon Sep 17 00:00:00 2001 From: author31 Date: Wed, 18 Mar 2026 16:11:35 +0800 Subject: [PATCH 3/8] add notebook utils, plotting video frames --- AGENTS.md | 1 + pyproject.toml | 2 + src/opai/__init__.py | 4 + src/opai/application/calibration.py | 37 ++ src/opai/domain/grid.py | 40 +++ src/opai/infrastructure/video.py | 38 ++ src/opai/presentation/__init__.py | 4 + src/opai/presentation/facade.py | 183 ++++++++-- tests/test_calibration.py | 73 ++++ tests/test_facade.py | 200 ++++++++++- uv.lock | 536 +++++++++++++++++++++++++++- 11 files changed, 1071 insertions(+), 47 deletions(-) create mode 100644 src/opai/domain/grid.py create mode 100644 src/opai/infrastructure/video.py diff --git a/AGENTS.md b/AGENTS.md index a30160a..4eda873 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -144,6 +144,7 @@ Outcome: - Make artifact creation deterministic where possible. - Make errors notebook-friendly and actionable. - Fail fast on required dependencies. If a feature depends on a package that is required by this repo, import it normally and let missing dependencies fail at import time rather than adding deferred runtime guards. Reserve lazy imports or fallback guards for truly optional integrations only. +- In `src/opai/presentation/facade.py`, prefer comprehensive public-function implementations over underscore-prefixed helper members. Keep the facade logic visible in the public functions rather than hiding it behind private abstractions unless the file would otherwise become unworkable. - Do not import types from the `typing` module. Python 3.10+ native annotations are the repo standard. - Prefer built-in generics such as `list[str]`, `dict[str, object]`, `tuple[int, ...]`, and unions like `Path | None`. - When a protocol-style annotation is needed, import it from `collections.abc` instead of `typing`. diff --git a/pyproject.toml b/pyproject.toml index a7ec117..6800cee 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,6 +9,8 @@ authors = [ requires-python = ">=3.10" dependencies = [ "jupyterlab>=4.5.6", + "matplotlib>=3.10.7", + "numpy>=2.2.6", "opencv-contrib-python>=4.13.0.92", "rich>=14.1.0", ] diff --git a/src/opai/__init__.py b/src/opai/__init__.py index 0caa38d..ab64a6e 100644 --- a/src/opai/__init__.py +++ b/src/opai/__init__.py @@ -3,10 +3,12 @@ add_mapping, browse_session, calibrate, + calibrate_with_video, get_context, init, list_sessions, main, + plot_video_frames, ) __all__ = [ @@ -14,8 +16,10 @@ "add_mapping", "browse_session", "calibrate", + "calibrate_with_video", "get_context", "init", "list_sessions", "main", + "plot_video_frames", ] diff --git a/src/opai/application/calibration.py b/src/opai/application/calibration.py index c10e807..5dafb72 100644 --- a/src/opai/application/calibration.py +++ b/src/opai/application/calibration.py @@ -1,6 +1,7 @@ from __future__ import annotations from collections.abc import Sequence +from pathlib import Path import cv2 import numpy as np @@ -9,6 +10,9 @@ from opai.domain.calibration import CalibrationIntrinsics, CalibrationResult from opai.domain.context import Context from opai.infrastructure.persistence import write_calibration_result +from opai.infrastructure.video import ( + sample_video_frames as sample_video_frames_from_path, +) def calibrate( @@ -100,6 +104,39 @@ def calibrate( return result +def sample_video_frames( + video_path: str | Path, + frame_sample_step: int, +) -> tuple[np.ndarray, ...]: + path = Path(video_path).expanduser() + if not path.exists(): + raise OPAIValidationError( + f"Calibration video does not exist: {path}", + details={"path": str(path)}, + ) + if not path.is_file(): + raise OPAIValidationError( + f"Calibration video path must point to a file: {path}", + details={"path": str(path)}, + ) + if frame_sample_step <= 0: + raise OPAIValidationError( + "frame_sample_step must be greater than 0.", + details={"frame_sample_step": frame_sample_step}, + ) + + frames = sample_video_frames_from_path(path, frame_sample_step) + if not frames: + raise OPAIWorkflowError( + "Calibration failed: video sampling produced no frames.", + details={ + "path": str(path), + "frame_sample_step": frame_sample_step, + }, + ) + return frames + + def _validate_inputs( frames: Sequence[np.ndarray], row_count: int, diff --git a/src/opai/domain/grid.py b/src/opai/domain/grid.py new file mode 100644 index 0000000..1e64077 --- /dev/null +++ b/src/opai/domain/grid.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +import math +from dataclasses import dataclass + + +@dataclass(frozen=True) +class PlotGrid: + item_count: int + nrows: int + ncols: int + + +def get_plot_grid( + item_count: int, + nrows: int | None = None, + ncols: int | None = None, +) -> PlotGrid: + if item_count <= 0: + raise ValueError("item_count must be positive.") + if nrows is not None and nrows <= 0: + raise ValueError("nrows must be positive when provided.") + if ncols is not None and ncols <= 0: + raise ValueError("ncols must be positive when provided.") + + if nrows is not None: + resolved_rows = nrows + resolved_cols = ncols or int(math.ceil(item_count / nrows)) + elif ncols is not None: + resolved_cols = ncols + resolved_rows = int(math.ceil(item_count / ncols)) + else: + resolved_rows = int(math.sqrt(item_count)) + resolved_cols = int(math.ceil(item_count / resolved_rows)) + + return PlotGrid( + item_count=item_count, + nrows=resolved_rows, + ncols=resolved_cols, + ) diff --git a/src/opai/infrastructure/video.py b/src/opai/infrastructure/video.py new file mode 100644 index 0000000..883fcbd --- /dev/null +++ b/src/opai/infrastructure/video.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +from pathlib import Path + +import cv2 +import numpy as np + +from opai.core.exceptions import OPAIWorkflowError + + +def sample_video_frames( + video_path: str | Path, + frame_sample_step: int, +) -> tuple[np.ndarray, ...]: + path = Path(video_path).expanduser() + capture = cv2.VideoCapture(str(path)) + if not capture.isOpened(): + capture.release() + raise OPAIWorkflowError( + f"Unable to open video for calibration sampling: {path}", + details={"path": str(path)}, + ) + + sampled_frames: list[np.ndarray] = [] + frame_index = 0 + + try: + while True: + ok, frame = capture.read() + if not ok: + break + if frame_index % frame_sample_step == 0: + sampled_frames.append(frame) + frame_index += 1 + finally: + capture.release() + + return tuple(sampled_frames) diff --git a/src/opai/presentation/__init__.py b/src/opai/presentation/__init__.py index 0caa38d..ab64a6e 100644 --- a/src/opai/presentation/__init__.py +++ b/src/opai/presentation/__init__.py @@ -3,10 +3,12 @@ add_mapping, browse_session, calibrate, + calibrate_with_video, get_context, init, list_sessions, main, + plot_video_frames, ) __all__ = [ @@ -14,8 +16,10 @@ "add_mapping", "browse_session", "calibrate", + "calibrate_with_video", "get_context", "init", "list_sessions", "main", + "plot_video_frames", ] diff --git a/src/opai/presentation/facade.py b/src/opai/presentation/facade.py index c6a441a..4c36620 100644 --- a/src/opai/presentation/facade.py +++ b/src/opai/presentation/facade.py @@ -13,14 +13,22 @@ ) from opai.domain.calibration import CalibrationResult from opai.domain.context import Context +from opai.domain.grid import get_plot_grid from opai.domain.session import DemoAsset, MappingAsset from opai.infrastructure.context_store import get_active_context, init_context -_SESSION_NAME_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$") +SESSION_NAME_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$") def init(name: str) -> Context: - normalized_name = _normalize_session_name(name) + if not isinstance(name, str) or not name.strip(): + raise OPAIValidationError("Session name must be a non-empty string.") + normalized_name = name.strip() + if not SESSION_NAME_PATTERN.fullmatch(normalized_name): + raise OPAIValidationError( + "Session name may only contain letters, numbers, '.', '_' and '-'.", + details={"session_name": normalized_name}, + ) return init_context(normalized_name) @@ -61,6 +69,98 @@ def calibrate( ) +def calibrate_with_video( + video_path: str | Path, + frame_sample_step: int, + row_count: int, + col_count: int, + square_length: float, + marker_length: float, + dictionary: str, +) -> CalibrationResult: + ctx = get_context() + try: + from opai.application.calibration import ( + calibrate as calibrate_with_context, + ) + from opai.application.calibration import ( + sample_video_frames as sample_video_frames_from_video, + ) + except ModuleNotFoundError as exc: + raise OPAIDependencyError( + "Calibration dependencies are unavailable. Install the project's " + "OpenCV calibration stack before calling opai.calibrate_with_video(...)." + ) from exc + + frames = sample_video_frames_from_video( + video_path=video_path, + frame_sample_step=frame_sample_step, + ) + return calibrate_with_context( + ctx=ctx, + frames=frames, + row_count=row_count, + col_count=col_count, + square_length=square_length, + marker_length=marker_length, + dictionary=dictionary, + ) + + +def plot_video_frames( + video_path: str | Path, + frame_sample_step: int, + nrows: int | None = None, + ncols: int | None = None, +) -> None: + try: + from opai.application.calibration import ( + sample_video_frames as sample_video_frames_from_video, + ) + except ModuleNotFoundError as exc: + raise OPAIDependencyError( + "Calibration dependencies are unavailable. Install the project's " + "OpenCV calibration stack before calling opai.plot_video_frames(...)." + ) from exc + + frames = sample_video_frames_from_video( + video_path=video_path, + frame_sample_step=frame_sample_step, + ) + try: + import matplotlib.pyplot as pyplot + except ModuleNotFoundError as exc: + raise OPAIDependencyError( + "Frame plotting requires the 'matplotlib' package. Install project " + "dependencies before calling opai.plot_video_frames(...)." + ) from exc + + try: + grid = get_plot_grid(len(frames), nrows=nrows, ncols=ncols) + except ValueError as exc: + raise OPAIValidationError(str(exc)) from exc + + fig, axes = pyplot.subplots( + grid.nrows, + grid.ncols, + figsize=(4 * grid.ncols, 3 * grid.nrows), + ) + flat_axes = np.atleast_1d(axes).reshape(-1) + + for axis, frame in zip(flat_axes, frames): + if frame.ndim == 2: + axis.imshow(frame) + else: + axis.imshow(frame[:, :, ::-1]) + axis.set_axis_off() + + for axis in flat_axes[len(frames) :]: + axis.set_axis_off() + + fig.tight_layout() + pyplot.show() + + def add_demos(video_paths: Sequence[str | Path]) -> tuple[DemoAsset, ...]: ctx = get_context() from opai.application.session import add_demos as add_demos_with_context @@ -76,7 +176,13 @@ def add_mapping(video_path: str | Path) -> MappingAsset: def list_sessions() -> list[str]: - Console, Tree = _load_rich_components() + try: + from rich.console import Console + from rich.tree import Tree + except ModuleNotFoundError as exc: + raise OPAIDependencyError( + "Session browsing requires the 'rich' package. Install project dependencies before calling opai.list_sessions(...) or opai.browse_session(...)." + ) from exc from opai.application.session import describe_sessions catalog = describe_sessions() @@ -104,8 +210,22 @@ def list_sessions() -> list[str]: def browse_session(name: str) -> list[str]: - normalized_name = _normalize_session_name(name) - Console, Tree = _load_rich_components() + if not isinstance(name, str) or not name.strip(): + raise OPAIValidationError("Session name must be a non-empty string.") + normalized_name = name.strip() + if not SESSION_NAME_PATTERN.fullmatch(normalized_name): + raise OPAIValidationError( + "Session name may only contain letters, numbers, '.', '_' and '-'.", + details={"session_name": normalized_name}, + ) + + try: + from rich.console import Console + from rich.tree import Tree + except ModuleNotFoundError as exc: + raise OPAIDependencyError( + "Session browsing requires the 'rich' package. Install project dependencies before calling opai.list_sessions(...) or opai.browse_session(...)." + ) from exc from opai.application.session import describe_session view = describe_session(normalized_name) @@ -123,44 +243,29 @@ def browse_session(name: str) -> list[str]: "[/]" ) session_branch.add(f"[dim]path:[/] [cyan]{view.session_path}[/]") - _append_tree_nodes(session_branch, view.tree_payload) + + pending_nodes: list[tuple[object, dict[str, dict]]] = [ + (session_branch, view.tree_payload) + ] + for parent, payload in pending_nodes: + for child_name, child in payload.items(): + is_directory = isinstance(child, dict) and bool(child) + label = ( + f"[bold blue]{child_name}/[/]" + if is_directory + else f"[green]{child_name}[/]" + ) + branch = parent.add(label) + if is_directory: + pending_nodes.append((branch, child)) + console.print(tree) return list(view.file_paths) def main() -> None: print( - "Use opai.init(name), opai.add_demos(...), opai.add_mapping(...), and opai.calibrate(...) from Python." + "Use opai.init(name), opai.add_demos(...), opai.add_mapping(...), " + "opai.calibrate(...), opai.calibrate_with_video(...), and " + "opai.plot_video_frames(...) from Python." ) - - -def _normalize_session_name(name: str) -> str: - if not isinstance(name, str) or not name.strip(): - raise OPAIValidationError("Session name must be a non-empty string.") - normalized_name = name.strip() - if not _SESSION_NAME_PATTERN.fullmatch(normalized_name): - raise OPAIValidationError( - "Session name may only contain letters, numbers, '.', '_' and '-'.", - details={"session_name": normalized_name}, - ) - return normalized_name - - -def _append_tree_nodes(tree, payload: dict[str, dict]) -> None: - for name, child in payload.items(): - is_directory = isinstance(child, dict) and bool(child) - label = f"[bold blue]{name}/[/]" if is_directory else f"[green]{name}[/]" - branch = tree.add(label) - if is_directory: - _append_tree_nodes(branch, child) - - -def _load_rich_components(): - try: - from rich.console import Console - from rich.tree import Tree - except ModuleNotFoundError as exc: - raise OPAIDependencyError( - "Session browsing requires the 'rich' package. Install project dependencies before calling opai.list_sessions(...) or opai.browse_session(...)." - ) from exc - return Console, Tree diff --git a/tests/test_calibration.py b/tests/test_calibration.py index 62e4267..f4b241d 100644 --- a/tests/test_calibration.py +++ b/tests/test_calibration.py @@ -11,9 +11,12 @@ _compute_mse_reprojection_error, _resolve_dictionary, calibrate, + sample_video_frames, ) from opai.core.exceptions import OPAIValidationError, OPAIWorkflowError from opai.domain.context import Context +from opai.domain.grid import get_plot_grid +from opai.infrastructure import video as video_module class DummyBoard: @@ -119,6 +122,76 @@ def test_compute_mse_reprojection_error_rejects_inconsistent_lengths() -> None: ) +def test_get_plot_grid_auto_computes_near_square_layout() -> None: + grid = get_plot_grid(5) + + assert grid.item_count == 5 + assert grid.nrows == 2 + assert grid.ncols == 3 + + +def test_sample_video_frames_rejects_non_positive_step(tmp_path) -> None: + video_path = tmp_path / "demo.mp4" + video_path.write_bytes(b"demo") + + with pytest.raises(OPAIValidationError, match="frame_sample_step"): + sample_video_frames(video_path, 0) + + +def test_sample_video_frames_rejects_missing_path(tmp_path) -> None: + with pytest.raises(OPAIValidationError, match="does not exist"): + sample_video_frames(tmp_path / "missing.mp4", 2) + + +def test_sample_video_frames_rejects_empty_sampling( + tmp_path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + video_path = tmp_path / "demo.mp4" + video_path.write_bytes(b"demo") + monkeypatch.setattr( + calibration_module, "sample_video_frames_from_path", lambda *_: () + ) + + with pytest.raises(OPAIWorkflowError, match="produced no frames"): + sample_video_frames(video_path, 2) + + +def test_infrastructure_video_sampling_starts_at_zero_and_respects_step( + monkeypatch: pytest.MonkeyPatch, + tmp_path, +) -> None: + frames = tuple( + np.full((2, 2, 3), fill_value=index, dtype=np.uint8) for index in range(5) + ) + + class FakeCapture: + def __init__(self, _path: str) -> None: + self._frames = list(frames) + self._released = False + + def isOpened(self) -> bool: + return True + + def read(self) -> tuple[bool, np.ndarray | None]: + if not self._frames: + return False, None + return True, self._frames.pop(0) + + def release(self) -> None: + self._released = True + + monkeypatch.setattr( + video_module, + "cv2", + SimpleNamespace(VideoCapture=FakeCapture), + ) + + sampled = video_module.sample_video_frames(tmp_path / "demo.mp4", 2) + + assert [int(frame[0, 0, 0]) for frame in sampled] == [0, 2, 4] + + def test_repo_exceptions_expose_error_codes_and_payload() -> None: error = OPAIValidationError( "Invalid board parameters.", diff --git a/tests/test_facade.py b/tests/test_facade.py index 085d900..09e4487 100644 --- a/tests/test_facade.py +++ b/tests/test_facade.py @@ -14,6 +14,7 @@ OPAIContextError, OPAIDependencyError, OPAIValidationError, + OPAIWorkflowError, ) from opai.infrastructure import context_store @@ -23,6 +24,15 @@ def test_calibrate_requires_context() -> None: opai.calibrate([], 3, 3, 1.0, 0.5, "DICT_4X4_50") +def test_calibrate_with_video_requires_context(tmp_path, monkeypatch) -> None: + monkeypatch.chdir(tmp_path) + video_path = tmp_path / "demo.mp4" + video_path.write_bytes(b"demo") + + with pytest.raises(OPAIContextError, match="Call opai.init"): + opai.calibrate_with_video(video_path, 2, 3, 3, 1.0, 0.5, "DICT_4X4_50") + + def test_init_creates_context_directory(tmp_path, monkeypatch) -> None: monkeypatch.chdir(tmp_path) ctx = opai.init("session-001") @@ -97,6 +107,140 @@ def test_calibrate_writes_artifact(tmp_path, monkeypatch) -> None: assert result.intrinsic_type == "FISHEYE" +def test_calibrate_with_video_writes_artifact_without_plotting( + tmp_path, + monkeypatch, +) -> None: + fake_cv2 = _build_fake_cv2() + monkeypatch.setattr(calibration_module, "cv2", fake_cv2) + monkeypatch.chdir(tmp_path) + opai.init("session-001") + + frames = tuple( + np.full((10, 12, 3), fill_value=index, dtype=np.uint8) for index in range(5) + ) + video_path = tmp_path / "demo.mp4" + video_path.write_bytes(b"demo") + monkeypatch.setattr( + calibration_module, + "sample_video_frames", + lambda **kwargs: frames, + ) + + def fail_matplotlib_import(name, globals=None, locals=None, fromlist=(), level=0): + if name == "matplotlib.pyplot": + pytest.fail("calibrate_with_video should not import matplotlib") + return builtin_import(name, globals, locals, fromlist, level) + + monkeypatch.setattr("builtins.__import__", fail_matplotlib_import) + + result = opai.calibrate_with_video( + video_path=video_path, + frame_sample_step=2, + row_count=3, + col_count=3, + square_length=1.0, + marker_length=0.5, + dictionary="DICT_4X4_50", + ) + + output_path = tmp_path / ".opai_sessions" / "session-001" / "calibration.json" + assert output_path.exists() + payload = json.loads(output_path.read_text(encoding="utf-8")) + assert payload["image_height"] == 10 + assert payload["image_width"] == 12 + assert result.intrinsic_type == "FISHEYE" + + +def test_plot_video_frames_uses_auto_grid_defaults_without_context( + tmp_path, + monkeypatch, +) -> None: + frames = tuple( + np.full((10, 12, 3), fill_value=index, dtype=np.uint8) for index in range(5) + ) + video_path = tmp_path / "demo.mp4" + video_path.write_bytes(b"demo") + monkeypatch.setattr(context_store, "_ACTIVE_CONTEXT", None) + monkeypatch.setattr( + calibration_module, + "sample_video_frames", + lambda **kwargs: frames, + ) + pyplot = _build_fake_pyplot(nrows=2, ncols=3) + monkeypatch.setitem(sys.modules, "matplotlib", SimpleNamespace(pyplot=pyplot)) + monkeypatch.setitem(sys.modules, "matplotlib.pyplot", pyplot) + + opai.plot_video_frames(video_path, frame_sample_step=2) + + assert pyplot.subplots_calls == [((2, 3), {"figsize": (12, 6)})] + assert pyplot.show_count == 1 + assert pyplot.figure.tight_layout_calls == 1 + assert np.array_equal( + pyplot.axes[0].images[0], + frames[0][:, :, ::-1], + ) + assert all(axis.axis_off_calls == 1 for axis in pyplot.axes[:5]) + assert pyplot.axes[5].axis_off_calls == 1 + + +def test_plot_video_frames_accepts_custom_grid(tmp_path, monkeypatch) -> None: + frames = tuple( + np.full((10, 12, 3), fill_value=index, dtype=np.uint8) for index in range(5) + ) + video_path = tmp_path / "demo.mp4" + video_path.write_bytes(b"demo") + monkeypatch.setattr( + calibration_module, + "sample_video_frames", + lambda **kwargs: frames, + ) + pyplot = _build_fake_pyplot(nrows=1, ncols=5) + monkeypatch.setitem(sys.modules, "matplotlib", SimpleNamespace(pyplot=pyplot)) + monkeypatch.setitem(sys.modules, "matplotlib.pyplot", pyplot) + + opai.plot_video_frames(video_path, frame_sample_step=2, nrows=1, ncols=5) + + assert pyplot.subplots_calls == [((1, 5), {"figsize": (20, 3)})] + assert pyplot.show_count == 1 + assert all(axis.axis_off_calls == 1 for axis in pyplot.axes) + + +def test_plot_video_frames_requires_matplotlib(tmp_path, monkeypatch) -> None: + video_path = tmp_path / "demo.mp4" + video_path.write_bytes(b"demo") + monkeypatch.setattr( + calibration_module, + "sample_video_frames", + lambda **kwargs: (np.zeros((4, 4, 3), dtype=np.uint8),), + ) + + def fake_import(name, globals=None, locals=None, fromlist=(), level=0): + if name == "matplotlib.pyplot": + raise ModuleNotFoundError("No module named 'matplotlib'") + return builtin_import(name, globals, locals, fromlist, level) + + monkeypatch.setattr("builtins.__import__", fake_import) + + with pytest.raises(OPAIDependencyError, match="matplotlib"): + opai.plot_video_frames(video_path, frame_sample_step=2) + + +def test_plot_video_frames_propagates_sampling_failure(tmp_path, monkeypatch) -> None: + video_path = tmp_path / "demo.mp4" + video_path.write_bytes(b"demo") + monkeypatch.setattr( + calibration_module, + "sample_video_frames", + lambda **kwargs: (_ for _ in ()).throw( + OPAIWorkflowError("Calibration failed: video sampling produced no frames.") + ), + ) + + with pytest.raises(OPAIWorkflowError, match="produced no frames"): + opai.plot_video_frames(video_path, frame_sample_step=2) + + def test_add_demos_requires_context(tmp_path, monkeypatch) -> None: monkeypatch.chdir(tmp_path) monkeypatch.setattr(context_store, "_ACTIVE_CONTEXT", None) @@ -249,6 +393,16 @@ def fake_import(name, globals=None, locals=None, fromlist=(), level=0): def _build_fake_cv2() -> SimpleNamespace: + def fake_calibrate_camera_charuco(**kwargs): + frame_count = len(kwargs["charucoCorners"]) + return ( + 0.1, + np.array([[10.0, 0.0, 5.0], [0.0, 20.0, 6.0], [0.0, 0.0, 1.0]]), + np.array([0.1, 0.2, 0.3, 0.4]), + [np.zeros((3, 1), dtype=np.float32) for _ in range(frame_count)], + [np.zeros((3, 1), dtype=np.float32) for _ in range(frame_count)], + ) + board = SimpleNamespace( getChessboardCorners=lambda: np.array( [ @@ -283,13 +437,7 @@ def _build_fake_cv2() -> SimpleNamespace: ), np.array([[0], [1], [2], [3]], dtype=np.int32), ), - calibrateCameraCharuco=lambda **kwargs: ( - 0.1, - np.array([[10.0, 0.0, 5.0], [0.0, 20.0, 6.0], [0.0, 0.0, 1.0]]), - np.array([0.1, 0.2, 0.3, 0.4]), - [np.zeros((3, 1), dtype=np.float32)], - [np.zeros((3, 1), dtype=np.float32)], - ), + calibrateCameraCharuco=fake_calibrate_camera_charuco, ) return SimpleNamespace( @@ -327,3 +475,41 @@ def print(self, *_args, **_kwargs) -> None: ) monkeypatch.setitem(sys.modules, "rich.tree", SimpleNamespace(Tree=FakeTree)) return recorder + + +def _build_fake_pyplot(*, nrows: int, ncols: int): + class FakeAxis: + def __init__(self) -> None: + self.images: list[np.ndarray] = [] + self.axis_off_calls = 0 + + def imshow(self, image: np.ndarray) -> None: + self.images.append(image) + + def set_axis_off(self) -> None: + self.axis_off_calls += 1 + + class FakeFigure: + def __init__(self) -> None: + self.tight_layout_calls = 0 + + def tight_layout(self) -> None: + self.tight_layout_calls += 1 + + class FakePyplot: + def __init__(self) -> None: + self.figure = FakeFigure() + self.axes = [FakeAxis() for _ in range(nrows * ncols)] + self.subplots_calls: list[ + tuple[tuple[int, int], dict[str, tuple[int, int]]] + ] = [] + self.show_count = 0 + + def subplots(self, rows: int, cols: int, **kwargs): + self.subplots_calls.append(((rows, cols), kwargs)) + return self.figure, np.array(self.axes, dtype=object).reshape(rows, cols) + + def show(self) -> None: + self.show_count += 1 + + return FakePyplot() diff --git a/uv.lock b/uv.lock index 6034924..79c356b 100644 --- a/uv.lock +++ b/uv.lock @@ -384,6 +384,172 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/60/97/891a0971e1e4a8c5d2b20bbe0e524dc04548d2307fee33cdeba148fd4fc7/comm-0.2.3-py3-none-any.whl", hash = "sha256:c615d91d75f7f04f095b30d1c1711babd43bdc6419c1be9886a85f2f4e489417", size = 7294, upload-time = "2025-07-25T14:02:02.896Z" }, ] +[[package]] +name = "contourpy" +version = "1.3.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +dependencies = [ + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/54/eb9bfc647b19f2009dd5c7f5ec51c4e6ca831725f1aea7a993034f483147/contourpy-1.3.2.tar.gz", hash = "sha256:b6945942715a034c671b7fc54f9588126b0b8bf23db2696e3ca8328f3ff0ab54", size = 13466130, upload-time = "2025-04-15T17:47:53.79Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/a3/da4153ec8fe25d263aa48c1a4cbde7f49b59af86f0b6f7862788c60da737/contourpy-1.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ba38e3f9f330af820c4b27ceb4b9c7feee5fe0493ea53a8720f4792667465934", size = 268551, upload-time = "2025-04-15T17:34:46.581Z" }, + { url = "https://files.pythonhosted.org/packages/2f/6c/330de89ae1087eb622bfca0177d32a7ece50c3ef07b28002de4757d9d875/contourpy-1.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dc41ba0714aa2968d1f8674ec97504a8f7e334f48eeacebcaa6256213acb0989", size = 253399, upload-time = "2025-04-15T17:34:51.427Z" }, + { url = "https://files.pythonhosted.org/packages/c1/bd/20c6726b1b7f81a8bee5271bed5c165f0a8e1f572578a9d27e2ccb763cb2/contourpy-1.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9be002b31c558d1ddf1b9b415b162c603405414bacd6932d031c5b5a8b757f0d", size = 312061, upload-time = "2025-04-15T17:34:55.961Z" }, + { url = "https://files.pythonhosted.org/packages/22/fc/a9665c88f8a2473f823cf1ec601de9e5375050f1958cbb356cdf06ef1ab6/contourpy-1.3.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8d2e74acbcba3bfdb6d9d8384cdc4f9260cae86ed9beee8bd5f54fee49a430b9", size = 351956, upload-time = "2025-04-15T17:35:00.992Z" }, + { url = "https://files.pythonhosted.org/packages/25/eb/9f0a0238f305ad8fb7ef42481020d6e20cf15e46be99a1fcf939546a177e/contourpy-1.3.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e259bced5549ac64410162adc973c5e2fb77f04df4a439d00b478e57a0e65512", size = 320872, upload-time = "2025-04-15T17:35:06.177Z" }, + { url = "https://files.pythonhosted.org/packages/32/5c/1ee32d1c7956923202f00cf8d2a14a62ed7517bdc0ee1e55301227fc273c/contourpy-1.3.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad687a04bc802cbe8b9c399c07162a3c35e227e2daccf1668eb1f278cb698631", size = 325027, upload-time = "2025-04-15T17:35:11.244Z" }, + { url = "https://files.pythonhosted.org/packages/83/bf/9baed89785ba743ef329c2b07fd0611d12bfecbedbdd3eeecf929d8d3b52/contourpy-1.3.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cdd22595308f53ef2f891040ab2b93d79192513ffccbd7fe19be7aa773a5e09f", size = 1306641, upload-time = "2025-04-15T17:35:26.701Z" }, + { url = "https://files.pythonhosted.org/packages/d4/cc/74e5e83d1e35de2d28bd97033426b450bc4fd96e092a1f7a63dc7369b55d/contourpy-1.3.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b4f54d6a2defe9f257327b0f243612dd051cc43825587520b1bf74a31e2f6ef2", size = 1374075, upload-time = "2025-04-15T17:35:43.204Z" }, + { url = "https://files.pythonhosted.org/packages/0c/42/17f3b798fd5e033b46a16f8d9fcb39f1aba051307f5ebf441bad1ecf78f8/contourpy-1.3.2-cp310-cp310-win32.whl", hash = "sha256:f939a054192ddc596e031e50bb13b657ce318cf13d264f095ce9db7dc6ae81c0", size = 177534, upload-time = "2025-04-15T17:35:46.554Z" }, + { url = "https://files.pythonhosted.org/packages/54/ec/5162b8582f2c994721018d0c9ece9dc6ff769d298a8ac6b6a652c307e7df/contourpy-1.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:c440093bbc8fc21c637c03bafcbef95ccd963bc6e0514ad887932c18ca2a759a", size = 221188, upload-time = "2025-04-15T17:35:50.064Z" }, + { url = "https://files.pythonhosted.org/packages/b3/b9/ede788a0b56fc5b071639d06c33cb893f68b1178938f3425debebe2dab78/contourpy-1.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6a37a2fb93d4df3fc4c0e363ea4d16f83195fc09c891bc8ce072b9d084853445", size = 269636, upload-time = "2025-04-15T17:35:54.473Z" }, + { url = "https://files.pythonhosted.org/packages/e6/75/3469f011d64b8bbfa04f709bfc23e1dd71be54d05b1b083be9f5b22750d1/contourpy-1.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b7cd50c38f500bbcc9b6a46643a40e0913673f869315d8e70de0438817cb7773", size = 254636, upload-time = "2025-04-15T17:35:58.283Z" }, + { url = "https://files.pythonhosted.org/packages/8d/2f/95adb8dae08ce0ebca4fd8e7ad653159565d9739128b2d5977806656fcd2/contourpy-1.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d6658ccc7251a4433eebd89ed2672c2ed96fba367fd25ca9512aa92a4b46c4f1", size = 313053, upload-time = "2025-04-15T17:36:03.235Z" }, + { url = "https://files.pythonhosted.org/packages/c3/a6/8ccf97a50f31adfa36917707fe39c9a0cbc24b3bbb58185577f119736cc9/contourpy-1.3.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:70771a461aaeb335df14deb6c97439973d253ae70660ca085eec25241137ef43", size = 352985, upload-time = "2025-04-15T17:36:08.275Z" }, + { url = "https://files.pythonhosted.org/packages/1d/b6/7925ab9b77386143f39d9c3243fdd101621b4532eb126743201160ffa7e6/contourpy-1.3.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:65a887a6e8c4cd0897507d814b14c54a8c2e2aa4ac9f7686292f9769fcf9a6ab", size = 323750, upload-time = "2025-04-15T17:36:13.29Z" }, + { url = "https://files.pythonhosted.org/packages/c2/f3/20c5d1ef4f4748e52d60771b8560cf00b69d5c6368b5c2e9311bcfa2a08b/contourpy-1.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3859783aefa2b8355697f16642695a5b9792e7a46ab86da1118a4a23a51a33d7", size = 326246, upload-time = "2025-04-15T17:36:18.329Z" }, + { url = "https://files.pythonhosted.org/packages/8c/e5/9dae809e7e0b2d9d70c52b3d24cba134dd3dad979eb3e5e71f5df22ed1f5/contourpy-1.3.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:eab0f6db315fa4d70f1d8ab514e527f0366ec021ff853d7ed6a2d33605cf4b83", size = 1308728, upload-time = "2025-04-15T17:36:33.878Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/0058ba34aeea35c0b442ae61a4f4d4ca84d6df8f91309bc2d43bb8dd248f/contourpy-1.3.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d91a3ccc7fea94ca0acab82ceb77f396d50a1f67412efe4c526f5d20264e6ecd", size = 1375762, upload-time = "2025-04-15T17:36:51.295Z" }, + { url = "https://files.pythonhosted.org/packages/09/33/7174bdfc8b7767ef2c08ed81244762d93d5c579336fc0b51ca57b33d1b80/contourpy-1.3.2-cp311-cp311-win32.whl", hash = "sha256:1c48188778d4d2f3d48e4643fb15d8608b1d01e4b4d6b0548d9b336c28fc9b6f", size = 178196, upload-time = "2025-04-15T17:36:55.002Z" }, + { url = "https://files.pythonhosted.org/packages/5e/fe/4029038b4e1c4485cef18e480b0e2cd2d755448bb071eb9977caac80b77b/contourpy-1.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:5ebac872ba09cb8f2131c46b8739a7ff71de28a24c869bcad554477eb089a878", size = 222017, upload-time = "2025-04-15T17:36:58.576Z" }, + { url = "https://files.pythonhosted.org/packages/34/f7/44785876384eff370c251d58fd65f6ad7f39adce4a093c934d4a67a7c6b6/contourpy-1.3.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4caf2bcd2969402bf77edc4cb6034c7dd7c0803213b3523f111eb7460a51b8d2", size = 271580, upload-time = "2025-04-15T17:37:03.105Z" }, + { url = "https://files.pythonhosted.org/packages/93/3b/0004767622a9826ea3d95f0e9d98cd8729015768075d61f9fea8eeca42a8/contourpy-1.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:82199cb78276249796419fe36b7386bd8d2cc3f28b3bc19fe2454fe2e26c4c15", size = 255530, upload-time = "2025-04-15T17:37:07.026Z" }, + { url = "https://files.pythonhosted.org/packages/e7/bb/7bd49e1f4fa805772d9fd130e0d375554ebc771ed7172f48dfcd4ca61549/contourpy-1.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:106fab697af11456fcba3e352ad50effe493a90f893fca6c2ca5c033820cea92", size = 307688, upload-time = "2025-04-15T17:37:11.481Z" }, + { url = "https://files.pythonhosted.org/packages/fc/97/e1d5dbbfa170725ef78357a9a0edc996b09ae4af170927ba8ce977e60a5f/contourpy-1.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d14f12932a8d620e307f715857107b1d1845cc44fdb5da2bc8e850f5ceba9f87", size = 347331, upload-time = "2025-04-15T17:37:18.212Z" }, + { url = "https://files.pythonhosted.org/packages/6f/66/e69e6e904f5ecf6901be3dd16e7e54d41b6ec6ae3405a535286d4418ffb4/contourpy-1.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:532fd26e715560721bb0d5fc7610fce279b3699b018600ab999d1be895b09415", size = 318963, upload-time = "2025-04-15T17:37:22.76Z" }, + { url = "https://files.pythonhosted.org/packages/a8/32/b8a1c8965e4f72482ff2d1ac2cd670ce0b542f203c8e1d34e7c3e6925da7/contourpy-1.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f26b383144cf2d2c29f01a1e8170f50dacf0eac02d64139dcd709a8ac4eb3cfe", size = 323681, upload-time = "2025-04-15T17:37:33.001Z" }, + { url = "https://files.pythonhosted.org/packages/30/c6/12a7e6811d08757c7162a541ca4c5c6a34c0f4e98ef2b338791093518e40/contourpy-1.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c49f73e61f1f774650a55d221803b101d966ca0c5a2d6d5e4320ec3997489441", size = 1308674, upload-time = "2025-04-15T17:37:48.64Z" }, + { url = "https://files.pythonhosted.org/packages/2a/8a/bebe5a3f68b484d3a2b8ffaf84704b3e343ef1addea528132ef148e22b3b/contourpy-1.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3d80b2c0300583228ac98d0a927a1ba6a2ba6b8a742463c564f1d419ee5b211e", size = 1380480, upload-time = "2025-04-15T17:38:06.7Z" }, + { url = "https://files.pythonhosted.org/packages/34/db/fcd325f19b5978fb509a7d55e06d99f5f856294c1991097534360b307cf1/contourpy-1.3.2-cp312-cp312-win32.whl", hash = "sha256:90df94c89a91b7362e1142cbee7568f86514412ab8a2c0d0fca72d7e91b62912", size = 178489, upload-time = "2025-04-15T17:38:10.338Z" }, + { url = "https://files.pythonhosted.org/packages/01/c8/fadd0b92ffa7b5eb5949bf340a63a4a496a6930a6c37a7ba0f12acb076d6/contourpy-1.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:8c942a01d9163e2e5cfb05cb66110121b8d07ad438a17f9e766317bcb62abf73", size = 223042, upload-time = "2025-04-15T17:38:14.239Z" }, + { url = "https://files.pythonhosted.org/packages/2e/61/5673f7e364b31e4e7ef6f61a4b5121c5f170f941895912f773d95270f3a2/contourpy-1.3.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:de39db2604ae755316cb5967728f4bea92685884b1e767b7c24e983ef5f771cb", size = 271630, upload-time = "2025-04-15T17:38:19.142Z" }, + { url = "https://files.pythonhosted.org/packages/ff/66/a40badddd1223822c95798c55292844b7e871e50f6bfd9f158cb25e0bd39/contourpy-1.3.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3f9e896f447c5c8618f1edb2bafa9a4030f22a575ec418ad70611450720b5b08", size = 255670, upload-time = "2025-04-15T17:38:23.688Z" }, + { url = "https://files.pythonhosted.org/packages/1e/c7/cf9fdee8200805c9bc3b148f49cb9482a4e3ea2719e772602a425c9b09f8/contourpy-1.3.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:71e2bd4a1c4188f5c2b8d274da78faab884b59df20df63c34f74aa1813c4427c", size = 306694, upload-time = "2025-04-15T17:38:28.238Z" }, + { url = "https://files.pythonhosted.org/packages/dd/e7/ccb9bec80e1ba121efbffad7f38021021cda5be87532ec16fd96533bb2e0/contourpy-1.3.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de425af81b6cea33101ae95ece1f696af39446db9682a0b56daaa48cfc29f38f", size = 345986, upload-time = "2025-04-15T17:38:33.502Z" }, + { url = "https://files.pythonhosted.org/packages/dc/49/ca13bb2da90391fa4219fdb23b078d6065ada886658ac7818e5441448b78/contourpy-1.3.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:977e98a0e0480d3fe292246417239d2d45435904afd6d7332d8455981c408b85", size = 318060, upload-time = "2025-04-15T17:38:38.672Z" }, + { url = "https://files.pythonhosted.org/packages/c8/65/5245ce8c548a8422236c13ffcdcdada6a2a812c361e9e0c70548bb40b661/contourpy-1.3.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:434f0adf84911c924519d2b08fc10491dd282b20bdd3fa8f60fd816ea0b48841", size = 322747, upload-time = "2025-04-15T17:38:43.712Z" }, + { url = "https://files.pythonhosted.org/packages/72/30/669b8eb48e0a01c660ead3752a25b44fdb2e5ebc13a55782f639170772f9/contourpy-1.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c66c4906cdbc50e9cba65978823e6e00b45682eb09adbb78c9775b74eb222422", size = 1308895, upload-time = "2025-04-15T17:39:00.224Z" }, + { url = "https://files.pythonhosted.org/packages/05/5a/b569f4250decee6e8d54498be7bdf29021a4c256e77fe8138c8319ef8eb3/contourpy-1.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8b7fc0cd78ba2f4695fd0a6ad81a19e7e3ab825c31b577f384aa9d7817dc3bef", size = 1379098, upload-time = "2025-04-15T17:43:29.649Z" }, + { url = "https://files.pythonhosted.org/packages/19/ba/b227c3886d120e60e41b28740ac3617b2f2b971b9f601c835661194579f1/contourpy-1.3.2-cp313-cp313-win32.whl", hash = "sha256:15ce6ab60957ca74cff444fe66d9045c1fd3e92c8936894ebd1f3eef2fff075f", size = 178535, upload-time = "2025-04-15T17:44:44.532Z" }, + { url = "https://files.pythonhosted.org/packages/12/6e/2fed56cd47ca739b43e892707ae9a13790a486a3173be063681ca67d2262/contourpy-1.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:e1578f7eafce927b168752ed7e22646dad6cd9bca673c60bff55889fa236ebf9", size = 223096, upload-time = "2025-04-15T17:44:48.194Z" }, + { url = "https://files.pythonhosted.org/packages/54/4c/e76fe2a03014a7c767d79ea35c86a747e9325537a8b7627e0e5b3ba266b4/contourpy-1.3.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0475b1f6604896bc7c53bb070e355e9321e1bc0d381735421a2d2068ec56531f", size = 285090, upload-time = "2025-04-15T17:43:34.084Z" }, + { url = "https://files.pythonhosted.org/packages/7b/e2/5aba47debd55d668e00baf9651b721e7733975dc9fc27264a62b0dd26eb8/contourpy-1.3.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:c85bb486e9be652314bb5b9e2e3b0d1b2e643d5eec4992c0fbe8ac71775da739", size = 268643, upload-time = "2025-04-15T17:43:38.626Z" }, + { url = "https://files.pythonhosted.org/packages/a1/37/cd45f1f051fe6230f751cc5cdd2728bb3a203f5619510ef11e732109593c/contourpy-1.3.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:745b57db7758f3ffc05a10254edd3182a2a83402a89c00957a8e8a22f5582823", size = 310443, upload-time = "2025-04-15T17:43:44.522Z" }, + { url = "https://files.pythonhosted.org/packages/8b/a2/36ea6140c306c9ff6dd38e3bcec80b3b018474ef4d17eb68ceecd26675f4/contourpy-1.3.2-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:970e9173dbd7eba9b4e01aab19215a48ee5dd3f43cef736eebde064a171f89a5", size = 349865, upload-time = "2025-04-15T17:43:49.545Z" }, + { url = "https://files.pythonhosted.org/packages/95/b7/2fc76bc539693180488f7b6cc518da7acbbb9e3b931fd9280504128bf956/contourpy-1.3.2-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c6c4639a9c22230276b7bffb6a850dfc8258a2521305e1faefe804d006b2e532", size = 321162, upload-time = "2025-04-15T17:43:54.203Z" }, + { url = "https://files.pythonhosted.org/packages/f4/10/76d4f778458b0aa83f96e59d65ece72a060bacb20cfbee46cf6cd5ceba41/contourpy-1.3.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc829960f34ba36aad4302e78eabf3ef16a3a100863f0d4eeddf30e8a485a03b", size = 327355, upload-time = "2025-04-15T17:44:01.025Z" }, + { url = "https://files.pythonhosted.org/packages/43/a3/10cf483ea683f9f8ab096c24bad3cce20e0d1dd9a4baa0e2093c1c962d9d/contourpy-1.3.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d32530b534e986374fc19eaa77fcb87e8a99e5431499949b828312bdcd20ac52", size = 1307935, upload-time = "2025-04-15T17:44:17.322Z" }, + { url = "https://files.pythonhosted.org/packages/78/73/69dd9a024444489e22d86108e7b913f3528f56cfc312b5c5727a44188471/contourpy-1.3.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:e298e7e70cf4eb179cc1077be1c725b5fd131ebc81181bf0c03525c8abc297fd", size = 1372168, upload-time = "2025-04-15T17:44:33.43Z" }, + { url = "https://files.pythonhosted.org/packages/0f/1b/96d586ccf1b1a9d2004dd519b25fbf104a11589abfd05484ff12199cca21/contourpy-1.3.2-cp313-cp313t-win32.whl", hash = "sha256:d0e589ae0d55204991450bb5c23f571c64fe43adaa53f93fc902a84c96f52fe1", size = 189550, upload-time = "2025-04-15T17:44:37.092Z" }, + { url = "https://files.pythonhosted.org/packages/b0/e6/6000d0094e8a5e32ad62591c8609e269febb6e4db83a1c75ff8868b42731/contourpy-1.3.2-cp313-cp313t-win_amd64.whl", hash = "sha256:78e9253c3de756b3f6a5174d024c4835acd59eb3f8e2ca13e775dbffe1558f69", size = 238214, upload-time = "2025-04-15T17:44:40.827Z" }, + { url = "https://files.pythonhosted.org/packages/33/05/b26e3c6ecc05f349ee0013f0bb850a761016d89cec528a98193a48c34033/contourpy-1.3.2-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:fd93cc7f3139b6dd7aab2f26a90dde0aa9fc264dbf70f6740d498a70b860b82c", size = 265681, upload-time = "2025-04-15T17:44:59.314Z" }, + { url = "https://files.pythonhosted.org/packages/2b/25/ac07d6ad12affa7d1ffed11b77417d0a6308170f44ff20fa1d5aa6333f03/contourpy-1.3.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:107ba8a6a7eec58bb475329e6d3b95deba9440667c4d62b9b6063942b61d7f16", size = 315101, upload-time = "2025-04-15T17:45:04.165Z" }, + { url = "https://files.pythonhosted.org/packages/8f/4d/5bb3192bbe9d3f27e3061a6a8e7733c9120e203cb8515767d30973f71030/contourpy-1.3.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:ded1706ed0c1049224531b81128efbd5084598f18d8a2d9efae833edbd2b40ad", size = 220599, upload-time = "2025-04-15T17:45:08.456Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c0/91f1215d0d9f9f343e4773ba6c9b89e8c0cc7a64a6263f21139da639d848/contourpy-1.3.2-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:5f5964cdad279256c084b69c3f412b7801e15356b16efa9d78aa974041903da0", size = 266807, upload-time = "2025-04-15T17:45:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/d4/79/6be7e90c955c0487e7712660d6cead01fa17bff98e0ea275737cc2bc8e71/contourpy-1.3.2-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49b65a95d642d4efa8f64ba12558fcb83407e58a2dfba9d796d77b63ccfcaff5", size = 318729, upload-time = "2025-04-15T17:45:20.166Z" }, + { url = "https://files.pythonhosted.org/packages/87/68/7f46fb537958e87427d98a4074bcde4b67a70b04900cfc5ce29bc2f556c1/contourpy-1.3.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:8c5acb8dddb0752bf252e01a3035b21443158910ac16a3b0d20e7fed7d534ce5", size = 221791, upload-time = "2025-04-15T17:45:24.794Z" }, +] + +[[package]] +name = "contourpy" +version = "1.3.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version >= '3.12' and python_full_version < '3.14'", + "python_full_version == '3.11.*'", +] +dependencies = [ + { name = "numpy", version = "2.4.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174, upload-time = "2025-07-26T12:03:12.549Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/2e/c4390a31919d8a78b90e8ecf87cd4b4c4f05a5b48d05ec17db8e5404c6f4/contourpy-1.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:709a48ef9a690e1343202916450bc48b9e51c049b089c7f79a267b46cffcdaa1", size = 288773, upload-time = "2025-07-26T12:01:02.277Z" }, + { url = "https://files.pythonhosted.org/packages/0d/44/c4b0b6095fef4dc9c420e041799591e3b63e9619e3044f7f4f6c21c0ab24/contourpy-1.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:23416f38bfd74d5d28ab8429cc4d63fa67d5068bd711a85edb1c3fb0c3e2f381", size = 270149, upload-time = "2025-07-26T12:01:04.072Z" }, + { url = "https://files.pythonhosted.org/packages/30/2e/dd4ced42fefac8470661d7cb7e264808425e6c5d56d175291e93890cce09/contourpy-1.3.3-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:929ddf8c4c7f348e4c0a5a3a714b5c8542ffaa8c22954862a46ca1813b667ee7", size = 329222, upload-time = "2025-07-26T12:01:05.688Z" }, + { url = "https://files.pythonhosted.org/packages/f2/74/cc6ec2548e3d276c71389ea4802a774b7aa3558223b7bade3f25787fafc2/contourpy-1.3.3-cp311-cp311-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9e999574eddae35f1312c2b4b717b7885d4edd6cb46700e04f7f02db454e67c1", size = 377234, upload-time = "2025-07-26T12:01:07.054Z" }, + { url = "https://files.pythonhosted.org/packages/03/b3/64ef723029f917410f75c09da54254c5f9ea90ef89b143ccadb09df14c15/contourpy-1.3.3-cp311-cp311-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0bf67e0e3f482cb69779dd3061b534eb35ac9b17f163d851e2a547d56dba0a3a", size = 380555, upload-time = "2025-07-26T12:01:08.801Z" }, + { url = "https://files.pythonhosted.org/packages/5f/4b/6157f24ca425b89fe2eb7e7be642375711ab671135be21e6faa100f7448c/contourpy-1.3.3-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:51e79c1f7470158e838808d4a996fa9bac72c498e93d8ebe5119bc1e6becb0db", size = 355238, upload-time = "2025-07-26T12:01:10.319Z" }, + { url = "https://files.pythonhosted.org/packages/98/56/f914f0dd678480708a04cfd2206e7c382533249bc5001eb9f58aa693e200/contourpy-1.3.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:598c3aaece21c503615fd59c92a3598b428b2f01bfb4b8ca9c4edeecc2438620", size = 1326218, upload-time = "2025-07-26T12:01:12.659Z" }, + { url = "https://files.pythonhosted.org/packages/fb/d7/4a972334a0c971acd5172389671113ae82aa7527073980c38d5868ff1161/contourpy-1.3.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:322ab1c99b008dad206d406bb61d014cf0174df491ae9d9d0fac6a6fda4f977f", size = 1392867, upload-time = "2025-07-26T12:01:15.533Z" }, + { url = "https://files.pythonhosted.org/packages/75/3e/f2cc6cd56dc8cff46b1a56232eabc6feea52720083ea71ab15523daab796/contourpy-1.3.3-cp311-cp311-win32.whl", hash = "sha256:fd907ae12cd483cd83e414b12941c632a969171bf90fc937d0c9f268a31cafff", size = 183677, upload-time = "2025-07-26T12:01:17.088Z" }, + { url = "https://files.pythonhosted.org/packages/98/4b/9bd370b004b5c9d8045c6c33cf65bae018b27aca550a3f657cdc99acdbd8/contourpy-1.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:3519428f6be58431c56581f1694ba8e50626f2dd550af225f82fb5f5814d2a42", size = 225234, upload-time = "2025-07-26T12:01:18.256Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b6/71771e02c2e004450c12b1120a5f488cad2e4d5b590b1af8bad060360fe4/contourpy-1.3.3-cp311-cp311-win_arm64.whl", hash = "sha256:15ff10bfada4bf92ec8b31c62bf7c1834c244019b4a33095a68000d7075df470", size = 193123, upload-time = "2025-07-26T12:01:19.848Z" }, + { url = "https://files.pythonhosted.org/packages/be/45/adfee365d9ea3d853550b2e735f9d66366701c65db7855cd07621732ccfc/contourpy-1.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b08a32ea2f8e42cf1d4be3169a98dd4be32bafe4f22b6c4cb4ba810fa9e5d2cb", size = 293419, upload-time = "2025-07-26T12:01:21.16Z" }, + { url = "https://files.pythonhosted.org/packages/53/3e/405b59cfa13021a56bba395a6b3aca8cec012b45bf177b0eaf7a202cde2c/contourpy-1.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:556dba8fb6f5d8742f2923fe9457dbdd51e1049c4a43fd3986a0b14a1d815fc6", size = 273979, upload-time = "2025-07-26T12:01:22.448Z" }, + { url = "https://files.pythonhosted.org/packages/d4/1c/a12359b9b2ca3a845e8f7f9ac08bdf776114eb931392fcad91743e2ea17b/contourpy-1.3.3-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92d9abc807cf7d0e047b95ca5d957cf4792fcd04e920ca70d48add15c1a90ea7", size = 332653, upload-time = "2025-07-26T12:01:24.155Z" }, + { url = "https://files.pythonhosted.org/packages/63/12/897aeebfb475b7748ea67b61e045accdfcf0d971f8a588b67108ed7f5512/contourpy-1.3.3-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2e8faa0ed68cb29af51edd8e24798bb661eac3bd9f65420c1887b6ca89987c8", size = 379536, upload-time = "2025-07-26T12:01:25.91Z" }, + { url = "https://files.pythonhosted.org/packages/43/8a/a8c584b82deb248930ce069e71576fc09bd7174bbd35183b7943fb1064fd/contourpy-1.3.3-cp312-cp312-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:626d60935cf668e70a5ce6ff184fd713e9683fb458898e4249b63be9e28286ea", size = 384397, upload-time = "2025-07-26T12:01:27.152Z" }, + { url = "https://files.pythonhosted.org/packages/cc/8f/ec6289987824b29529d0dfda0d74a07cec60e54b9c92f3c9da4c0ac732de/contourpy-1.3.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d00e655fcef08aba35ec9610536bfe90267d7ab5ba944f7032549c55a146da1", size = 362601, upload-time = "2025-07-26T12:01:28.808Z" }, + { url = "https://files.pythonhosted.org/packages/05/0a/a3fe3be3ee2dceb3e615ebb4df97ae6f3828aa915d3e10549ce016302bd1/contourpy-1.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:451e71b5a7d597379ef572de31eeb909a87246974d960049a9848c3bc6c41bf7", size = 1331288, upload-time = "2025-07-26T12:01:31.198Z" }, + { url = "https://files.pythonhosted.org/packages/33/1d/acad9bd4e97f13f3e2b18a3977fe1b4a37ecf3d38d815333980c6c72e963/contourpy-1.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:459c1f020cd59fcfe6650180678a9993932d80d44ccde1fa1868977438f0b411", size = 1403386, upload-time = "2025-07-26T12:01:33.947Z" }, + { url = "https://files.pythonhosted.org/packages/cf/8f/5847f44a7fddf859704217a99a23a4f6417b10e5ab1256a179264561540e/contourpy-1.3.3-cp312-cp312-win32.whl", hash = "sha256:023b44101dfe49d7d53932be418477dba359649246075c996866106da069af69", size = 185018, upload-time = "2025-07-26T12:01:35.64Z" }, + { url = "https://files.pythonhosted.org/packages/19/e8/6026ed58a64563186a9ee3f29f41261fd1828f527dd93d33b60feca63352/contourpy-1.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:8153b8bfc11e1e4d75bcb0bff1db232f9e10b274e0929de9d608027e0d34ff8b", size = 226567, upload-time = "2025-07-26T12:01:36.804Z" }, + { url = "https://files.pythonhosted.org/packages/d1/e2/f05240d2c39a1ed228d8328a78b6f44cd695f7ef47beb3e684cf93604f86/contourpy-1.3.3-cp312-cp312-win_arm64.whl", hash = "sha256:07ce5ed73ecdc4a03ffe3e1b3e3c1166db35ae7584be76f65dbbe28a7791b0cc", size = 193655, upload-time = "2025-07-26T12:01:37.999Z" }, + { url = "https://files.pythonhosted.org/packages/68/35/0167aad910bbdb9599272bd96d01a9ec6852f36b9455cf2ca67bd4cc2d23/contourpy-1.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:177fb367556747a686509d6fef71d221a4b198a3905fe824430e5ea0fda54eb5", size = 293257, upload-time = "2025-07-26T12:01:39.367Z" }, + { url = "https://files.pythonhosted.org/packages/96/e4/7adcd9c8362745b2210728f209bfbcf7d91ba868a2c5f40d8b58f54c509b/contourpy-1.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d002b6f00d73d69333dac9d0b8d5e84d9724ff9ef044fd63c5986e62b7c9e1b1", size = 274034, upload-time = "2025-07-26T12:01:40.645Z" }, + { url = "https://files.pythonhosted.org/packages/73/23/90e31ceeed1de63058a02cb04b12f2de4b40e3bef5e082a7c18d9c8ae281/contourpy-1.3.3-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:348ac1f5d4f1d66d3322420f01d42e43122f43616e0f194fc1c9f5d830c5b286", size = 334672, upload-time = "2025-07-26T12:01:41.942Z" }, + { url = "https://files.pythonhosted.org/packages/ed/93/b43d8acbe67392e659e1d984700e79eb67e2acb2bd7f62012b583a7f1b55/contourpy-1.3.3-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:655456777ff65c2c548b7c454af9c6f33f16c8884f11083244b5819cc214f1b5", size = 381234, upload-time = "2025-07-26T12:01:43.499Z" }, + { url = "https://files.pythonhosted.org/packages/46/3b/bec82a3ea06f66711520f75a40c8fc0b113b2a75edb36aa633eb11c4f50f/contourpy-1.3.3-cp313-cp313-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:644a6853d15b2512d67881586bd03f462c7ab755db95f16f14d7e238f2852c67", size = 385169, upload-time = "2025-07-26T12:01:45.219Z" }, + { url = "https://files.pythonhosted.org/packages/4b/32/e0f13a1c5b0f8572d0ec6ae2f6c677b7991fafd95da523159c19eff0696a/contourpy-1.3.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4debd64f124ca62069f313a9cb86656ff087786016d76927ae2cf37846b006c9", size = 362859, upload-time = "2025-07-26T12:01:46.519Z" }, + { url = "https://files.pythonhosted.org/packages/33/71/e2a7945b7de4e58af42d708a219f3b2f4cff7386e6b6ab0a0fa0033c49a9/contourpy-1.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a15459b0f4615b00bbd1e91f1b9e19b7e63aea7483d03d804186f278c0af2659", size = 1332062, upload-time = "2025-07-26T12:01:48.964Z" }, + { url = "https://files.pythonhosted.org/packages/12/fc/4e87ac754220ccc0e807284f88e943d6d43b43843614f0a8afa469801db0/contourpy-1.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ca0fdcd73925568ca027e0b17ab07aad764be4706d0a925b89227e447d9737b7", size = 1403932, upload-time = "2025-07-26T12:01:51.979Z" }, + { url = "https://files.pythonhosted.org/packages/a6/2e/adc197a37443f934594112222ac1aa7dc9a98faf9c3842884df9a9d8751d/contourpy-1.3.3-cp313-cp313-win32.whl", hash = "sha256:b20c7c9a3bf701366556e1b1984ed2d0cedf999903c51311417cf5f591d8c78d", size = 185024, upload-time = "2025-07-26T12:01:53.245Z" }, + { url = "https://files.pythonhosted.org/packages/18/0b/0098c214843213759692cc638fce7de5c289200a830e5035d1791d7a2338/contourpy-1.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:1cadd8b8969f060ba45ed7c1b714fe69185812ab43bd6b86a9123fe8f99c3263", size = 226578, upload-time = "2025-07-26T12:01:54.422Z" }, + { url = "https://files.pythonhosted.org/packages/8a/9a/2f6024a0c5995243cd63afdeb3651c984f0d2bc727fd98066d40e141ad73/contourpy-1.3.3-cp313-cp313-win_arm64.whl", hash = "sha256:fd914713266421b7536de2bfa8181aa8c699432b6763a0ea64195ebe28bff6a9", size = 193524, upload-time = "2025-07-26T12:01:55.73Z" }, + { url = "https://files.pythonhosted.org/packages/c0/b3/f8a1a86bd3298513f500e5b1f5fd92b69896449f6cab6a146a5d52715479/contourpy-1.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:88df9880d507169449d434c293467418b9f6cbe82edd19284aa0409e7fdb933d", size = 306730, upload-time = "2025-07-26T12:01:57.051Z" }, + { url = "https://files.pythonhosted.org/packages/3f/11/4780db94ae62fc0c2053909b65dc3246bd7cecfc4f8a20d957ad43aa4ad8/contourpy-1.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d06bb1f751ba5d417047db62bca3c8fde202b8c11fb50742ab3ab962c81e8216", size = 287897, upload-time = "2025-07-26T12:01:58.663Z" }, + { url = "https://files.pythonhosted.org/packages/ae/15/e59f5f3ffdd6f3d4daa3e47114c53daabcb18574a26c21f03dc9e4e42ff0/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e4e6b05a45525357e382909a4c1600444e2a45b4795163d3b22669285591c1ae", size = 326751, upload-time = "2025-07-26T12:02:00.343Z" }, + { url = "https://files.pythonhosted.org/packages/0f/81/03b45cfad088e4770b1dcf72ea78d3802d04200009fb364d18a493857210/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab3074b48c4e2cf1a960e6bbeb7f04566bf36b1861d5c9d4d8ac04b82e38ba20", size = 375486, upload-time = "2025-07-26T12:02:02.128Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ba/49923366492ffbdd4486e970d421b289a670ae8cf539c1ea9a09822b371a/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c3d53c796f8647d6deb1abe867daeb66dcc8a97e8455efa729516b997b8ed99", size = 388106, upload-time = "2025-07-26T12:02:03.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/52/5b00ea89525f8f143651f9f03a0df371d3cbd2fccd21ca9b768c7a6500c2/contourpy-1.3.3-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50ed930df7289ff2a8d7afeb9603f8289e5704755c7e5c3bbd929c90c817164b", size = 352548, upload-time = "2025-07-26T12:02:05.165Z" }, + { url = "https://files.pythonhosted.org/packages/32/1d/a209ec1a3a3452d490f6b14dd92e72280c99ae3d1e73da74f8277d4ee08f/contourpy-1.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4feffb6537d64b84877da813a5c30f1422ea5739566abf0bd18065ac040e120a", size = 1322297, upload-time = "2025-07-26T12:02:07.379Z" }, + { url = "https://files.pythonhosted.org/packages/bc/9e/46f0e8ebdd884ca0e8877e46a3f4e633f6c9c8c4f3f6e72be3fe075994aa/contourpy-1.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2b7e9480ffe2b0cd2e787e4df64270e3a0440d9db8dc823312e2c940c167df7e", size = 1391023, upload-time = "2025-07-26T12:02:10.171Z" }, + { url = "https://files.pythonhosted.org/packages/b9/70/f308384a3ae9cd2209e0849f33c913f658d3326900d0ff5d378d6a1422d2/contourpy-1.3.3-cp313-cp313t-win32.whl", hash = "sha256:283edd842a01e3dcd435b1c5116798d661378d83d36d337b8dde1d16a5fc9ba3", size = 196157, upload-time = "2025-07-26T12:02:11.488Z" }, + { url = "https://files.pythonhosted.org/packages/b2/dd/880f890a6663b84d9e34a6f88cded89d78f0091e0045a284427cb6b18521/contourpy-1.3.3-cp313-cp313t-win_amd64.whl", hash = "sha256:87acf5963fc2b34825e5b6b048f40e3635dd547f590b04d2ab317c2619ef7ae8", size = 240570, upload-time = "2025-07-26T12:02:12.754Z" }, + { url = "https://files.pythonhosted.org/packages/80/99/2adc7d8ffead633234817ef8e9a87115c8a11927a94478f6bb3d3f4d4f7d/contourpy-1.3.3-cp313-cp313t-win_arm64.whl", hash = "sha256:3c30273eb2a55024ff31ba7d052dde990d7d8e5450f4bbb6e913558b3d6c2301", size = 199713, upload-time = "2025-07-26T12:02:14.4Z" }, + { url = "https://files.pythonhosted.org/packages/72/8b/4546f3ab60f78c514ffb7d01a0bd743f90de36f0019d1be84d0a708a580a/contourpy-1.3.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fde6c716d51c04b1c25d0b90364d0be954624a0ee9d60e23e850e8d48353d07a", size = 292189, upload-time = "2025-07-26T12:02:16.095Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e1/3542a9cb596cadd76fcef413f19c79216e002623158befe6daa03dbfa88c/contourpy-1.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:cbedb772ed74ff5be440fa8eee9bd49f64f6e3fc09436d9c7d8f1c287b121d77", size = 273251, upload-time = "2025-07-26T12:02:17.524Z" }, + { url = "https://files.pythonhosted.org/packages/b1/71/f93e1e9471d189f79d0ce2497007731c1e6bf9ef6d1d61b911430c3db4e5/contourpy-1.3.3-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22e9b1bd7a9b1d652cd77388465dc358dafcd2e217d35552424aa4f996f524f5", size = 335810, upload-time = "2025-07-26T12:02:18.9Z" }, + { url = "https://files.pythonhosted.org/packages/91/f9/e35f4c1c93f9275d4e38681a80506b5510e9327350c51f8d4a5a724d178c/contourpy-1.3.3-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a22738912262aa3e254e4f3cb079a95a67132fc5a063890e224393596902f5a4", size = 382871, upload-time = "2025-07-26T12:02:20.418Z" }, + { url = "https://files.pythonhosted.org/packages/b5/71/47b512f936f66a0a900d81c396a7e60d73419868fba959c61efed7a8ab46/contourpy-1.3.3-cp314-cp314-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:afe5a512f31ee6bd7d0dda52ec9864c984ca3d66664444f2d72e0dc4eb832e36", size = 386264, upload-time = "2025-07-26T12:02:21.916Z" }, + { url = "https://files.pythonhosted.org/packages/04/5f/9ff93450ba96b09c7c2b3f81c94de31c89f92292f1380261bd7195bea4ea/contourpy-1.3.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f64836de09927cba6f79dcd00fdd7d5329f3fccc633468507079c829ca4db4e3", size = 363819, upload-time = "2025-07-26T12:02:23.759Z" }, + { url = "https://files.pythonhosted.org/packages/3e/a6/0b185d4cc480ee494945cde102cb0149ae830b5fa17bf855b95f2e70ad13/contourpy-1.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1fd43c3be4c8e5fd6e4f2baeae35ae18176cf2e5cced681cca908addf1cdd53b", size = 1333650, upload-time = "2025-07-26T12:02:26.181Z" }, + { url = "https://files.pythonhosted.org/packages/43/d7/afdc95580ca56f30fbcd3060250f66cedbde69b4547028863abd8aa3b47e/contourpy-1.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6afc576f7b33cf00996e5c1102dc2a8f7cc89e39c0b55df93a0b78c1bd992b36", size = 1404833, upload-time = "2025-07-26T12:02:28.782Z" }, + { url = "https://files.pythonhosted.org/packages/e2/e2/366af18a6d386f41132a48f033cbd2102e9b0cf6345d35ff0826cd984566/contourpy-1.3.3-cp314-cp314-win32.whl", hash = "sha256:66c8a43a4f7b8df8b71ee1840e4211a3c8d93b214b213f590e18a1beca458f7d", size = 189692, upload-time = "2025-07-26T12:02:30.128Z" }, + { url = "https://files.pythonhosted.org/packages/7d/c2/57f54b03d0f22d4044b8afb9ca0e184f8b1afd57b4f735c2fa70883dc601/contourpy-1.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:cf9022ef053f2694e31d630feaacb21ea24224be1c3ad0520b13d844274614fd", size = 232424, upload-time = "2025-07-26T12:02:31.395Z" }, + { url = "https://files.pythonhosted.org/packages/18/79/a9416650df9b525737ab521aa181ccc42d56016d2123ddcb7b58e926a42c/contourpy-1.3.3-cp314-cp314-win_arm64.whl", hash = "sha256:95b181891b4c71de4bb404c6621e7e2390745f887f2a026b2d99e92c17892339", size = 198300, upload-time = "2025-07-26T12:02:32.956Z" }, + { url = "https://files.pythonhosted.org/packages/1f/42/38c159a7d0f2b7b9c04c64ab317042bb6952b713ba875c1681529a2932fe/contourpy-1.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:33c82d0138c0a062380332c861387650c82e4cf1747aaa6938b9b6516762e772", size = 306769, upload-time = "2025-07-26T12:02:34.2Z" }, + { url = "https://files.pythonhosted.org/packages/c3/6c/26a8205f24bca10974e77460de68d3d7c63e282e23782f1239f226fcae6f/contourpy-1.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ea37e7b45949df430fe649e5de8351c423430046a2af20b1c1961cae3afcda77", size = 287892, upload-time = "2025-07-26T12:02:35.807Z" }, + { url = "https://files.pythonhosted.org/packages/66/06/8a475c8ab718ebfd7925661747dbb3c3ee9c82ac834ccb3570be49d129f4/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d304906ecc71672e9c89e87c4675dc5c2645e1f4269a5063b99b0bb29f232d13", size = 326748, upload-time = "2025-07-26T12:02:37.193Z" }, + { url = "https://files.pythonhosted.org/packages/b4/a3/c5ca9f010a44c223f098fccd8b158bb1cb287378a31ac141f04730dc49be/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca658cd1a680a5c9ea96dc61cdbae1e85c8f25849843aa799dfd3cb370ad4fbe", size = 375554, upload-time = "2025-07-26T12:02:38.894Z" }, + { url = "https://files.pythonhosted.org/packages/80/5b/68bd33ae63fac658a4145088c1e894405e07584a316738710b636c6d0333/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ab2fd90904c503739a75b7c8c5c01160130ba67944a7b77bbf36ef8054576e7f", size = 388118, upload-time = "2025-07-26T12:02:40.642Z" }, + { url = "https://files.pythonhosted.org/packages/40/52/4c285a6435940ae25d7410a6c36bda5145839bc3f0beb20c707cda18b9d2/contourpy-1.3.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b7301b89040075c30e5768810bc96a8e8d78085b47d8be6e4c3f5a0b4ed478a0", size = 352555, upload-time = "2025-07-26T12:02:42.25Z" }, + { url = "https://files.pythonhosted.org/packages/24/ee/3e81e1dd174f5c7fefe50e85d0892de05ca4e26ef1c9a59c2a57e43b865a/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2a2a8b627d5cc6b7c41a4beff6c5ad5eb848c88255fda4a8745f7e901b32d8e4", size = 1322295, upload-time = "2025-07-26T12:02:44.668Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b2/6d913d4d04e14379de429057cd169e5e00f6c2af3bb13e1710bcbdb5da12/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fd6ec6be509c787f1caf6b247f0b1ca598bef13f4ddeaa126b7658215529ba0f", size = 1391027, upload-time = "2025-07-26T12:02:47.09Z" }, + { url = "https://files.pythonhosted.org/packages/93/8a/68a4ec5c55a2971213d29a9374913f7e9f18581945a7a31d1a39b5d2dfe5/contourpy-1.3.3-cp314-cp314t-win32.whl", hash = "sha256:e74a9a0f5e3fff48fb5a7f2fd2b9b70a3fe014a67522f79b7cca4c0c7e43c9ae", size = 202428, upload-time = "2025-07-26T12:02:48.691Z" }, + { url = "https://files.pythonhosted.org/packages/fa/96/fd9f641ffedc4fa3ace923af73b9d07e869496c9cc7a459103e6e978992f/contourpy-1.3.3-cp314-cp314t-win_amd64.whl", hash = "sha256:13b68d6a62db8eafaebb8039218921399baf6e47bf85006fd8529f2a08ef33fc", size = 250331, upload-time = "2025-07-26T12:02:50.137Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8c/469afb6465b853afff216f9528ffda78a915ff880ed58813ba4faf4ba0b6/contourpy-1.3.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b7448cb5a725bb1e35ce88771b86fba35ef418952474492cf7c764059933ff8b", size = 203831, upload-time = "2025-07-26T12:02:51.449Z" }, + { url = "https://files.pythonhosted.org/packages/a5/29/8dcfe16f0107943fa92388c23f6e05cff0ba58058c4c95b00280d4c75a14/contourpy-1.3.3-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:cd5dfcaeb10f7b7f9dc8941717c6c2ade08f587be2226222c12b25f0483ed497", size = 278809, upload-time = "2025-07-26T12:02:52.74Z" }, + { url = "https://files.pythonhosted.org/packages/85/a9/8b37ef4f7dafeb335daee3c8254645ef5725be4d9c6aa70b50ec46ef2f7e/contourpy-1.3.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:0c1fc238306b35f246d61a1d416a627348b5cf0648648a031e14bb8705fcdfe8", size = 261593, upload-time = "2025-07-26T12:02:54.037Z" }, + { url = "https://files.pythonhosted.org/packages/0a/59/ebfb8c677c75605cc27f7122c90313fd2f375ff3c8d19a1694bda74aaa63/contourpy-1.3.3-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70f9aad7de812d6541d29d2bbf8feb22ff7e1c299523db288004e3157ff4674e", size = 302202, upload-time = "2025-07-26T12:02:55.947Z" }, + { url = "https://files.pythonhosted.org/packages/3c/37/21972a15834d90bfbfb009b9d004779bd5a07a0ec0234e5ba8f64d5736f4/contourpy-1.3.3-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5ed3657edf08512fc3fe81b510e35c2012fbd3081d2e26160f27ca28affec989", size = 329207, upload-time = "2025-07-26T12:02:57.468Z" }, + { url = "https://files.pythonhosted.org/packages/0c/58/bd257695f39d05594ca4ad60df5bcb7e32247f9951fd09a9b8edb82d1daa/contourpy-1.3.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:3d1a3799d62d45c18bafd41c5fa05120b96a28079f2393af559b843d1a966a77", size = 225315, upload-time = "2025-07-26T12:02:58.801Z" }, +] + +[[package]] +name = "cycler" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a9/95/a3dbbb5028f35eafb79008e7522a75244477d2838f38cbb722248dabc2a8/cycler-0.12.1.tar.gz", hash = "sha256:88bb128f02ba341da8ef447245a9e138fae777f6a23943da4540077d3601eb1c", size = 7615, upload-time = "2023-10-07T05:32:18.335Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321, upload-time = "2023-10-07T05:32:16.783Z" }, +] + [[package]] name = "debugpy" version = "1.8.20" @@ -445,7 +611,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -479,6 +645,63 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a4/a5/842ae8f0c08b61d6484b52f99a03510a3a72d23141942d216ebe81fefbce/filelock-3.25.2-py3-none-any.whl", hash = "sha256:ca8afb0da15f229774c9ad1b455ed96e85a81373065fb10446672f64444ddf70", size = 26759, upload-time = "2026-03-11T20:45:37.437Z" }, ] +[[package]] +name = "fonttools" +version = "4.62.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/08/7012b00a9a5874311b639c3920270c36ee0c445b69d9989a85e5c92ebcb0/fonttools-4.62.1.tar.gz", hash = "sha256:e54c75fd6041f1122476776880f7c3c3295ffa31962dc6ebe2543c00dca58b5d", size = 3580737, upload-time = "2026-03-13T13:54:25.52Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/ff/532ed43808b469c807e8cb6b21358da3fe6fd51486b3a8c93db0bb5d957f/fonttools-4.62.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ad5cca75776cd453b1b035b530e943334957ae152a36a88a320e779d61fc980c", size = 2873740, upload-time = "2026-03-13T13:52:11.822Z" }, + { url = "https://files.pythonhosted.org/packages/85/e4/2318d2b430562da7227010fb2bb029d2fa54d7b46443ae8942bab224e2a0/fonttools-4.62.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0b3ae47e8636156a9accff64c02c0924cbebad62854c4a6dbdc110cd5b4b341a", size = 2417649, upload-time = "2026-03-13T13:52:14.605Z" }, + { url = "https://files.pythonhosted.org/packages/4c/28/40f15523b5188598018e7956899fed94eb7debec89e2dd70cb4a8df90492/fonttools-4.62.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c9b9e288b4da2f64fd6180644221749de651703e8d0c16bd4b719533a3a7d6e3", size = 4935213, upload-time = "2026-03-13T13:52:17.399Z" }, + { url = "https://files.pythonhosted.org/packages/42/09/7dbe3d7023f57d9b580cfa832109d521988112fd59dddfda3fddda8218f9/fonttools-4.62.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7bca7a1c1faf235ffe25d4f2e555246b4750220b38de8261d94ebc5ce8a23c23", size = 4892374, upload-time = "2026-03-13T13:52:20.175Z" }, + { url = "https://files.pythonhosted.org/packages/d1/2d/84509a2e32cb925371560ef5431365d8da2183c11d98e5b4b8b4e42426a5/fonttools-4.62.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b4e0fcf265ad26e487c56cb12a42dffe7162de708762db951e1b3f755319507d", size = 4911856, upload-time = "2026-03-13T13:52:22.777Z" }, + { url = "https://files.pythonhosted.org/packages/a5/80/df28131379eed93d9e6e6fccd3bf6e3d077bebbfe98cc83f21bbcd83ed02/fonttools-4.62.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2d850f66830a27b0d498ee05adb13a3781637b1826982cd7e2b3789ef0cc71ae", size = 5031712, upload-time = "2026-03-13T13:52:25.14Z" }, + { url = "https://files.pythonhosted.org/packages/3d/03/3c8f09aad64230cd6d921ae7a19f9603c36f70930b00459f112706f6769a/fonttools-4.62.1-cp310-cp310-win32.whl", hash = "sha256:486f32c8047ccd05652aba17e4a8819a3a9d78570eb8a0e3b4503142947880ed", size = 1507878, upload-time = "2026-03-13T13:52:28.149Z" }, + { url = "https://files.pythonhosted.org/packages/dd/ec/f53f626f8f3e89f4cadd8fc08f3452c8fd182c951ad5caa35efac22b29ab/fonttools-4.62.1-cp310-cp310-win_amd64.whl", hash = "sha256:5a648bde915fba9da05ae98856987ca91ba832949a9e2888b48c47ef8b96c5a9", size = 1556766, upload-time = "2026-03-13T13:52:30.814Z" }, + { url = "https://files.pythonhosted.org/packages/88/39/23ff32561ec8d45a4d48578b4d241369d9270dc50926c017570e60893701/fonttools-4.62.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:40975849bac44fb0b9253d77420c6d8b523ac4dcdcefeff6e4d706838a5b80f7", size = 2871039, upload-time = "2026-03-13T13:52:33.127Z" }, + { url = "https://files.pythonhosted.org/packages/24/7f/66d3f8a9338a9b67fe6e1739f47e1cd5cee78bd3bc1206ef9b0b982289a5/fonttools-4.62.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9dde91633f77fa576879a0c76b1d89de373cae751a98ddf0109d54e173b40f14", size = 2416346, upload-time = "2026-03-13T13:52:35.676Z" }, + { url = "https://files.pythonhosted.org/packages/aa/53/5276ceba7bff95da7793a07c5284e1da901cf00341ce5e2f3273056c0cca/fonttools-4.62.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6acb4109f8bee00fec985c8c7afb02299e35e9c94b57287f3ea542f28bd0b0a7", size = 5100897, upload-time = "2026-03-13T13:52:38.102Z" }, + { url = "https://files.pythonhosted.org/packages/cc/a1/40a5c4d8e28b0851d53a8eeeb46fbd73c325a2a9a165f290a5ed90e6c597/fonttools-4.62.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1c5c25671ce8805e0d080e2ffdeca7f1e86778c5cbfbeae86d7f866d8830517b", size = 5071078, upload-time = "2026-03-13T13:52:41.305Z" }, + { url = "https://files.pythonhosted.org/packages/e3/be/d378fca4c65ea1956fee6d90ace6e861776809cbbc5af22388a090c3c092/fonttools-4.62.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a5d8825e1140f04e6c99bb7d37a9e31c172f3bc208afbe02175339e699c710e1", size = 5076908, upload-time = "2026-03-13T13:52:44.122Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d9/ae6a1d0693a4185a84605679c8a1f719a55df87b9c6e8e817bfdd9ef5936/fonttools-4.62.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:268abb1cb221e66c014acc234e872b7870d8b5d4657a83a8f4205094c32d2416", size = 5202275, upload-time = "2026-03-13T13:52:46.591Z" }, + { url = "https://files.pythonhosted.org/packages/54/6c/af95d9c4efb15cabff22642b608342f2bd67137eea6107202d91b5b03184/fonttools-4.62.1-cp311-cp311-win32.whl", hash = "sha256:942b03094d7edbb99bdf1ae7e9090898cad7bf9030b3d21f33d7072dbcb51a53", size = 2293075, upload-time = "2026-03-13T13:52:48.711Z" }, + { url = "https://files.pythonhosted.org/packages/d3/97/bf54c5b3f2be34e1f143e6db838dfdc54f2ffa3e68c738934c82f3b2a08d/fonttools-4.62.1-cp311-cp311-win_amd64.whl", hash = "sha256:e8514f4924375f77084e81467e63238b095abda5107620f49421c368a6017ed2", size = 2344593, upload-time = "2026-03-13T13:52:50.725Z" }, + { url = "https://files.pythonhosted.org/packages/47/d4/dbacced3953544b9a93088cc10ef2b596d348c983d5c67a404fa41ec51ba/fonttools-4.62.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:90365821debbd7db678809c7491ca4acd1e0779b9624cdc6ddaf1f31992bf974", size = 2870219, upload-time = "2026-03-13T13:52:53.664Z" }, + { url = "https://files.pythonhosted.org/packages/66/9e/a769c8e99b81e5a87ab7e5e7236684de4e96246aae17274e5347d11ebd78/fonttools-4.62.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:12859ff0b47dd20f110804c3e0d0970f7b832f561630cd879969011541a464a9", size = 2414891, upload-time = "2026-03-13T13:52:56.493Z" }, + { url = "https://files.pythonhosted.org/packages/69/64/f19a9e3911968c37e1e620e14dfc5778299e1474f72f4e57c5ec771d9489/fonttools-4.62.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c125ffa00c3d9003cdaaf7f2c79e6e535628093e14b5de1dccb08859b680936", size = 5033197, upload-time = "2026-03-13T13:52:59.179Z" }, + { url = "https://files.pythonhosted.org/packages/9b/8a/99c8b3c3888c5c474c08dbfd7c8899786de9604b727fcefb055b42c84bba/fonttools-4.62.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:149f7d84afca659d1a97e39a4778794a2f83bf344c5ee5134e09995086cc2392", size = 4988768, upload-time = "2026-03-13T13:53:02.761Z" }, + { url = "https://files.pythonhosted.org/packages/d1/c6/0f904540d3e6ab463c1243a0d803504826a11604c72dd58c2949796a1762/fonttools-4.62.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0aa72c43a601cfa9273bb1ae0518f1acadc01ee181a6fc60cd758d7fdadffc04", size = 4971512, upload-time = "2026-03-13T13:53:05.678Z" }, + { url = "https://files.pythonhosted.org/packages/29/0b/5cbef6588dc9bd6b5c9ad6a4d5a8ca384d0cea089da31711bbeb4f9654a6/fonttools-4.62.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:19177c8d96c7c36359266e571c5173bcee9157b59cfc8cb0153c5673dc5a3a7d", size = 5122723, upload-time = "2026-03-13T13:53:08.662Z" }, + { url = "https://files.pythonhosted.org/packages/4a/47/b3a5342d381595ef439adec67848bed561ab7fdb1019fa522e82101b7d9c/fonttools-4.62.1-cp312-cp312-win32.whl", hash = "sha256:a24decd24d60744ee8b4679d38e88b8303d86772053afc29b19d23bb8207803c", size = 2281278, upload-time = "2026-03-13T13:53:10.998Z" }, + { url = "https://files.pythonhosted.org/packages/28/b1/0c2ab56a16f409c6c8a68816e6af707827ad5d629634691ff60a52879792/fonttools-4.62.1-cp312-cp312-win_amd64.whl", hash = "sha256:9e7863e10b3de72376280b515d35b14f5eeed639d1aa7824f4cf06779ec65e42", size = 2331414, upload-time = "2026-03-13T13:53:13.992Z" }, + { url = "https://files.pythonhosted.org/packages/3b/56/6f389de21c49555553d6a5aeed5ac9767631497ac836c4f076273d15bd72/fonttools-4.62.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:c22b1014017111c401469e3acc5433e6acf6ebcc6aa9efb538a533c800971c79", size = 2865155, upload-time = "2026-03-13T13:53:16.132Z" }, + { url = "https://files.pythonhosted.org/packages/03/c5/0e3966edd5ec668d41dfe418787726752bc07e2f5fd8c8f208615e61fa89/fonttools-4.62.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:68959f5fc58ed4599b44aad161c2837477d7f35f5f79402d97439974faebfebe", size = 2412802, upload-time = "2026-03-13T13:53:18.878Z" }, + { url = "https://files.pythonhosted.org/packages/52/94/e6ac4b44026de7786fe46e3bfa0c87e51d5d70a841054065d49cd62bb909/fonttools-4.62.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef46db46c9447103b8f3ff91e8ba009d5fe181b1920a83757a5762551e32bb68", size = 5013926, upload-time = "2026-03-13T13:53:21.379Z" }, + { url = "https://files.pythonhosted.org/packages/e2/98/8b1e801939839d405f1f122e7d175cebe9aeb4e114f95bfc45e3152af9a7/fonttools-4.62.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6706d1cb1d5e6251a97ad3c1b9347505c5615c112e66047abbef0f8545fa30d1", size = 4964575, upload-time = "2026-03-13T13:53:23.857Z" }, + { url = "https://files.pythonhosted.org/packages/46/76/7d051671e938b1881670528fec69cc4044315edd71a229c7fd712eaa5119/fonttools-4.62.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2e7abd2b1e11736f58c1de27819e1955a53267c21732e78243fa2fa2e5c1e069", size = 4953693, upload-time = "2026-03-13T13:53:26.569Z" }, + { url = "https://files.pythonhosted.org/packages/1f/ae/b41f8628ec0be3c1b934fc12b84f4576a5c646119db4d3bdd76a217c90b5/fonttools-4.62.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:403d28ce06ebfc547fbcb0cb8b7f7cc2f7a2d3e1a67ba9a34b14632df9e080f9", size = 5094920, upload-time = "2026-03-13T13:53:29.329Z" }, + { url = "https://files.pythonhosted.org/packages/f2/f6/53a1e9469331a23dcc400970a27a4caa3d9f6edbf5baab0260285238b884/fonttools-4.62.1-cp313-cp313-win32.whl", hash = "sha256:93c316e0f5301b2adbe6a5f658634307c096fd5aae60a5b3412e4f3e1728ab24", size = 2279928, upload-time = "2026-03-13T13:53:32.352Z" }, + { url = "https://files.pythonhosted.org/packages/38/60/35186529de1db3c01f5ad625bde07c1f576305eab6d86bbda4c58445f721/fonttools-4.62.1-cp313-cp313-win_amd64.whl", hash = "sha256:7aa21ff53e28a9c2157acbc44e5b401149d3c9178107130e82d74ceb500e5056", size = 2330514, upload-time = "2026-03-13T13:53:34.991Z" }, + { url = "https://files.pythonhosted.org/packages/36/f0/2888cdac391807d68d90dcb16ef858ddc1b5309bfc6966195a459dd326e2/fonttools-4.62.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fa1d16210b6b10a826d71bed68dd9ec24a9e218d5a5e2797f37c573e7ec215ca", size = 2864442, upload-time = "2026-03-13T13:53:37.509Z" }, + { url = "https://files.pythonhosted.org/packages/4b/b2/e521803081f8dc35990816b82da6360fa668a21b44da4b53fc9e77efcd62/fonttools-4.62.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:aa69d10ed420d8121118e628ad47d86e4caa79ba37f968597b958f6cceab7eca", size = 2410901, upload-time = "2026-03-13T13:53:40.55Z" }, + { url = "https://files.pythonhosted.org/packages/00/a4/8c3511ff06e53110039358dbbdc1a65d72157a054638387aa2ada300a8b8/fonttools-4.62.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd13b7999d59c5eb1c2b442eb2d0c427cb517a0b7a1f5798fc5c9e003f5ff782", size = 4999608, upload-time = "2026-03-13T13:53:42.798Z" }, + { url = "https://files.pythonhosted.org/packages/28/63/cd0c3b26afe60995a5295f37c246a93d454023726c3261cfbb3559969bb9/fonttools-4.62.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8d337fdd49a79b0d51c4da87bc38169d21c3abbf0c1aa9367eff5c6656fb6dae", size = 4912726, upload-time = "2026-03-13T13:53:45.405Z" }, + { url = "https://files.pythonhosted.org/packages/70/b9/ac677cb07c24c685cf34f64e140617d58789d67a3dd524164b63648c6114/fonttools-4.62.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d241cdc4a67b5431c6d7f115fdf63335222414995e3a1df1a41e1182acd4bcc7", size = 4951422, upload-time = "2026-03-13T13:53:48.326Z" }, + { url = "https://files.pythonhosted.org/packages/e6/10/11c08419a14b85b7ca9a9faca321accccc8842dd9e0b1c8a72908de05945/fonttools-4.62.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c05557a78f8fa514da0f869556eeda40887a8abc77c76ee3f74cf241778afd5a", size = 5060979, upload-time = "2026-03-13T13:53:51.366Z" }, + { url = "https://files.pythonhosted.org/packages/4e/3c/12eea4a4cf054e7ab058ed5ceada43b46809fce2bf319017c4d63ae55bb4/fonttools-4.62.1-cp314-cp314-win32.whl", hash = "sha256:49a445d2f544ce4a69338694cad575ba97b9a75fff02720da0882d1a73f12800", size = 2283733, upload-time = "2026-03-13T13:53:53.606Z" }, + { url = "https://files.pythonhosted.org/packages/6b/67/74b070029043186b5dd13462c958cb7c7f811be0d2e634309d9a1ffb1505/fonttools-4.62.1-cp314-cp314-win_amd64.whl", hash = "sha256:1eecc128c86c552fb963fe846ca4e011b1be053728f798185a1687502f6d398e", size = 2335663, upload-time = "2026-03-13T13:53:56.23Z" }, + { url = "https://files.pythonhosted.org/packages/42/c5/4d2ed3ca6e33617fc5624467da353337f06e7f637707478903c785bd8e20/fonttools-4.62.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:1596aeaddf7f78e21e68293c011316a25267b3effdaccaf4d59bc9159d681b82", size = 2947288, upload-time = "2026-03-13T13:53:59.397Z" }, + { url = "https://files.pythonhosted.org/packages/1f/e9/7ab11ddfda48ed0f89b13380e5595ba572619c27077be0b2c447a63ff351/fonttools-4.62.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:8f8fca95d3bb3208f59626a4b0ea6e526ee51f5a8ad5d91821c165903e8d9260", size = 2449023, upload-time = "2026-03-13T13:54:01.642Z" }, + { url = "https://files.pythonhosted.org/packages/b2/10/a800fa090b5e8819942e54e19b55fc7c21fe14a08757c3aa3ca8db358939/fonttools-4.62.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee91628c08e76f77b533d65feb3fbe6d9dad699f95be51cf0d022db94089cdc4", size = 5137599, upload-time = "2026-03-13T13:54:04.495Z" }, + { url = "https://files.pythonhosted.org/packages/37/dc/8ccd45033fffd74deb6912fa1ca524643f584b94c87a16036855b498a1ed/fonttools-4.62.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5f37df1cac61d906e7b836abe356bc2f34c99d4477467755c216b72aa3dc748b", size = 4920933, upload-time = "2026-03-13T13:54:07.557Z" }, + { url = "https://files.pythonhosted.org/packages/99/eb/e618adefb839598d25ac8136cd577925d6c513dc0d931d93b8af956210f0/fonttools-4.62.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:92bb00a947e666169c99b43753c4305fc95a890a60ef3aeb2a6963e07902cc87", size = 5016232, upload-time = "2026-03-13T13:54:10.611Z" }, + { url = "https://files.pythonhosted.org/packages/d9/5f/9b5c9bfaa8ec82def8d8168c4f13615990d6ce5996fe52bd49bfb5e05134/fonttools-4.62.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:bdfe592802ef939a0e33106ea4a318eeb17822c7ee168c290273cbd5fabd746c", size = 5042987, upload-time = "2026-03-13T13:54:13.569Z" }, + { url = "https://files.pythonhosted.org/packages/90/aa/dfbbe24c6a6afc5c203d90cc0343e24bcbb09e76d67c4d6eef8c2558d7ba/fonttools-4.62.1-cp314-cp314t-win32.whl", hash = "sha256:b820fcb92d4655513d8402d5b219f94481c4443d825b4372c75a2072aa4b357a", size = 2348021, upload-time = "2026-03-13T13:54:16.98Z" }, + { url = "https://files.pythonhosted.org/packages/13/6f/ae9c4e4dd417948407b680855c2c7790efb52add6009aaecff1e3bc50e8e/fonttools-4.62.1-cp314-cp314t-win_amd64.whl", hash = "sha256:59b372b4f0e113d3746b88985f1c796e7bf830dd54b28374cd85c2b8acd7583e", size = 2414147, upload-time = "2026-03-13T13:54:19.416Z" }, + { url = "https://files.pythonhosted.org/packages/fd/ba/56147c165442cc5ba7e82ecf301c9a68353cede498185869e6e02b4c264f/fonttools-4.62.1-py3-none-any.whl", hash = "sha256:7487782e2113861f4ddcc07c3436450659e3caa5e470b27dc2177cade2d8e7fd", size = 1152647, upload-time = "2026-03-13T13:54:22.735Z" }, +] + [[package]] name = "fqdn" version = "1.5.1" @@ -914,6 +1137,130 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e0/07/a000fe835f76b7e1143242ab1122e6362ef1c03f23f83a045c38859c2ae0/jupyterlab_server-2.28.0-py3-none-any.whl", hash = "sha256:e4355b148fdcf34d312bbbc80f22467d6d20460e8b8736bf235577dd18506968", size = 59830, upload-time = "2025-10-22T13:59:16.767Z" }, ] +[[package]] +name = "kiwisolver" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/67/9c61eccb13f0bdca9307614e782fec49ffdde0f7a2314935d489fa93cd9c/kiwisolver-1.5.0.tar.gz", hash = "sha256:d4193f3d9dc3f6f79aaed0e5637f45d98850ebf01f7ca20e69457f3e8946b66a", size = 103482, upload-time = "2026-03-09T13:15:53.382Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ac/f8/06549565caa026e540b7e7bab5c5a90eb7ca986015f4c48dace243cd24d9/kiwisolver-1.5.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:32cc0a5365239a6ea0c6ed461e8838d053b57e397443c0ca894dcc8e388d4374", size = 122802, upload-time = "2026-03-09T13:12:37.515Z" }, + { url = "https://files.pythonhosted.org/packages/84/eb/8476a0818850c563ff343ea7c9c05dcdcbd689a38e01aa31657df01f91fa/kiwisolver-1.5.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:cc0b66c1eec9021353a4b4483afb12dfd50e3669ffbb9152d6842eb34c7e29fd", size = 66216, upload-time = "2026-03-09T13:12:38.812Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c4/f9c8a6b4c21aed4198566e45923512986d6cef530e7263b3a5f823546561/kiwisolver-1.5.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:86e0287879f75621ae85197b0877ed2f8b7aa57b511c7331dce2eb6f4de7d476", size = 63917, upload-time = "2026-03-09T13:12:40.053Z" }, + { url = "https://files.pythonhosted.org/packages/f1/0e/ba4ae25d03722f64de8b2c13e80d82ab537a06b30fc7065183c6439357e3/kiwisolver-1.5.0-cp310-cp310-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:62f59da443c4f4849f73a51a193b1d9d258dcad0c41bc4d1b8fb2bcc04bfeb22", size = 1628776, upload-time = "2026-03-09T13:12:41.976Z" }, + { url = "https://files.pythonhosted.org/packages/8a/e4/3f43a011bc8a0860d1c96f84d32fa87439d3feedf66e672fef03bf5e8bac/kiwisolver-1.5.0-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9190426b7aa26c5229501fa297b8d0653cfd3f5a36f7990c264e157cbf886b3b", size = 1228164, upload-time = "2026-03-09T13:12:44.002Z" }, + { url = "https://files.pythonhosted.org/packages/4b/34/3a901559a1e0c218404f9a61a93be82d45cb8f44453ba43088644980f033/kiwisolver-1.5.0-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c8277104ded0a51e699c8c3aff63ce2c56d4ed5519a5f73e0fd7057f959a2b9e", size = 1246656, upload-time = "2026-03-09T13:12:45.557Z" }, + { url = "https://files.pythonhosted.org/packages/87/9e/f78c466ea20527822b95ad38f141f2de1dcd7f23fb8716b002b0d91bbe59/kiwisolver-1.5.0-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8f9baf6f0a6e7571c45c8863010b45e837c3ee1c2c77fcd6ef423be91b21fedb", size = 1295562, upload-time = "2026-03-09T13:12:47.562Z" }, + { url = "https://files.pythonhosted.org/packages/0a/66/fd0e4a612e3a286c24e6d6f3a5428d11258ed1909bc530ba3b59807fd980/kiwisolver-1.5.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cff8e5383db4989311f99e814feeb90c4723eb4edca425b9d5d9c3fefcdd9537", size = 2178473, upload-time = "2026-03-09T13:12:50.254Z" }, + { url = "https://files.pythonhosted.org/packages/dc/8e/6cac929e0049539e5ee25c1ee937556f379ba5204840d03008363ced662d/kiwisolver-1.5.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:ebae99ed6764f2b5771c522477b311be313e8841d2e0376db2b10922daebbba4", size = 2274035, upload-time = "2026-03-09T13:12:51.785Z" }, + { url = "https://files.pythonhosted.org/packages/ca/d3/9d0c18f1b52ea8074b792452cf17f1f5a56bd0302a85191f405cfbf9da16/kiwisolver-1.5.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:d5cd5189fc2b6a538b75ae45433140c4823463918f7b1617c31e68b085c0022c", size = 2443217, upload-time = "2026-03-09T13:12:53.329Z" }, + { url = "https://files.pythonhosted.org/packages/45/2a/6e19368803a038b2a90857bf4ee9e3c7b667216d045866bf22d3439fd75e/kiwisolver-1.5.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f42c23db5d1521218a3276bb08666dcb662896a0be7347cba864eca45ff64ede", size = 2249196, upload-time = "2026-03-09T13:12:55.057Z" }, + { url = "https://files.pythonhosted.org/packages/75/2b/3f641dfcbe72e222175d626bacf2f72c3b34312afec949dd1c50afa400f5/kiwisolver-1.5.0-cp310-cp310-win_amd64.whl", hash = "sha256:94eff26096eb5395136634622515b234ecb6c9979824c1f5004c6e3c3c85ccd2", size = 73389, upload-time = "2026-03-09T13:12:56.496Z" }, + { url = "https://files.pythonhosted.org/packages/da/88/299b137b9e0025d8982e03d2d52c123b0a2b159e84b0ef1501ef446339cf/kiwisolver-1.5.0-cp310-cp310-win_arm64.whl", hash = "sha256:dd952e03bfbb096cfe2dd35cd9e00f269969b67536cb4370994afc20ff2d0875", size = 64782, upload-time = "2026-03-09T13:12:57.609Z" }, + { url = "https://files.pythonhosted.org/packages/12/dd/a495a9c104be1c476f0386e714252caf2b7eca883915422a64c50b88c6f5/kiwisolver-1.5.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9eed0f7edbb274413b6ee781cca50541c8c0facd3d6fd289779e494340a2b85c", size = 122798, upload-time = "2026-03-09T13:12:58.963Z" }, + { url = "https://files.pythonhosted.org/packages/11/60/37b4047a2af0cf5ef6d8b4b26e91829ae6fc6a2d1f74524bcb0e7cd28a32/kiwisolver-1.5.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3c4923e404d6bcd91b6779c009542e5647fef32e4a5d75e115e3bbac6f2335eb", size = 66216, upload-time = "2026-03-09T13:13:00.155Z" }, + { url = "https://files.pythonhosted.org/packages/0a/aa/510dc933d87767584abfe03efa445889996c70c2990f6f87c3ebaa0a18c5/kiwisolver-1.5.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0df54df7e686afa55e6f21fb86195224a6d9beb71d637e8d7920c95cf0f89aac", size = 63911, upload-time = "2026-03-09T13:13:01.671Z" }, + { url = "https://files.pythonhosted.org/packages/80/46/bddc13df6c2a40741e0cc7865bb1c9ed4796b6760bd04ce5fae3928ef917/kiwisolver-1.5.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2517e24d7315eb51c10664cdb865195df38ab74456c677df67bb47f12d088a27", size = 1438209, upload-time = "2026-03-09T13:13:03.385Z" }, + { url = "https://files.pythonhosted.org/packages/fd/d6/76621246f5165e5372f02f5e6f3f48ea336a8f9e96e43997d45b240ed8cd/kiwisolver-1.5.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff710414307fefa903e0d9bdf300972f892c23477829f49504e59834f4195398", size = 1248888, upload-time = "2026-03-09T13:13:05.231Z" }, + { url = "https://files.pythonhosted.org/packages/b2/c1/31559ec6fb39a5b48035ce29bb63ade628f321785f38c384dee3e2c08bc1/kiwisolver-1.5.0-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6176c1811d9d5a04fa391c490cc44f451e240697a16977f11c6f722efb9041db", size = 1266304, upload-time = "2026-03-09T13:13:06.743Z" }, + { url = "https://files.pythonhosted.org/packages/5e/ef/1cb8276f2d29cc6a41e0a042f27946ca347d3a4a75acf85d0a16aa6dcc82/kiwisolver-1.5.0-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50847dca5d197fcbd389c805aa1a1cf32f25d2e7273dc47ab181a517666b68cc", size = 1319650, upload-time = "2026-03-09T13:13:08.607Z" }, + { url = "https://files.pythonhosted.org/packages/4c/e4/5ba3cecd7ce6236ae4a80f67e5d5531287337d0e1f076ca87a5abe4cd5d0/kiwisolver-1.5.0-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:01808c6d15f4c3e8559595d6d1fe6411c68e4a3822b4b9972b44473b24f4e679", size = 970949, upload-time = "2026-03-09T13:13:10.299Z" }, + { url = "https://files.pythonhosted.org/packages/5a/69/dc61f7ae9a2f071f26004ced87f078235b5507ab6e5acd78f40365655034/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f1f9f4121ec58628c96baa3de1a55a4e3a333c5102c8e94b64e23bf7b2083309", size = 2199125, upload-time = "2026-03-09T13:13:11.841Z" }, + { url = "https://files.pythonhosted.org/packages/e5/7b/abbe0f1b5afa85f8d084b73e90e5f801c0939eba16ac2e49af7c61a6c28d/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:b7d335370ae48a780c6e6a6bbfa97342f563744c39c35562f3f367665f5c1de2", size = 2293783, upload-time = "2026-03-09T13:13:14.399Z" }, + { url = "https://files.pythonhosted.org/packages/8a/80/5908ae149d96d81580d604c7f8aefd0e98f4fd728cf172f477e9f2a81744/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:800ee55980c18545af444d93fdd60c56b580db5cc54867d8cbf8a1dc0829938c", size = 1960726, upload-time = "2026-03-09T13:13:16.047Z" }, + { url = "https://files.pythonhosted.org/packages/84/08/a78cb776f8c085b7143142ce479859cfec086bd09ee638a317040b6ef420/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:c438f6ca858697c9ab67eb28246c92508af972e114cac34e57a6d4ba17a3ac08", size = 2464738, upload-time = "2026-03-09T13:13:17.897Z" }, + { url = "https://files.pythonhosted.org/packages/b1/e1/65584da5356ed6cb12c63791a10b208860ac40a83de165cb6a6751a686e3/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:8c63c91f95173f9c2a67c7c526b2cea976828a0e7fced9cdcead2802dc10f8a4", size = 2270718, upload-time = "2026-03-09T13:13:19.421Z" }, + { url = "https://files.pythonhosted.org/packages/be/6c/28f17390b62b8f2f520e2915095b3c94d88681ecf0041e75389d9667f202/kiwisolver-1.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:beb7f344487cdcb9e1efe4b7a29681b74d34c08f0043a327a74da852a6749e7b", size = 73480, upload-time = "2026-03-09T13:13:20.818Z" }, + { url = "https://files.pythonhosted.org/packages/d8/0e/2ee5debc4f77a625778fec5501ff3e8036fe361b7ee28ae402a485bb9694/kiwisolver-1.5.0-cp311-cp311-win_arm64.whl", hash = "sha256:ad4ae4ffd1ee9cd11357b4c66b612da9888f4f4daf2f36995eda64bd45370cac", size = 64930, upload-time = "2026-03-09T13:13:21.997Z" }, + { url = "https://files.pythonhosted.org/packages/4d/b2/818b74ebea34dabe6d0c51cb1c572e046730e64844da6ed646d5298c40ce/kiwisolver-1.5.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:4e9750bc21b886308024f8a54ccb9a2cc38ac9fa813bf4348434e3d54f337ff9", size = 123158, upload-time = "2026-03-09T13:13:23.127Z" }, + { url = "https://files.pythonhosted.org/packages/bf/d9/405320f8077e8e1c5c4bd6adc45e1e6edf6d727b6da7f2e2533cf58bff71/kiwisolver-1.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:72ec46b7eba5b395e0a7b63025490d3214c11013f4aacb4f5e8d6c3041829588", size = 66388, upload-time = "2026-03-09T13:13:24.765Z" }, + { url = "https://files.pythonhosted.org/packages/99/9f/795fedf35634f746151ca8839d05681ceb6287fbed6cc1c9bf235f7887c2/kiwisolver-1.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ed3a984b31da7481b103f68776f7128a89ef26ed40f4dc41a2223cda7fb24819", size = 64068, upload-time = "2026-03-09T13:13:25.878Z" }, + { url = "https://files.pythonhosted.org/packages/c4/13/680c54afe3e65767bed7ec1a15571e1a2f1257128733851ade24abcefbcc/kiwisolver-1.5.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb5136fb5352d3f422df33f0c879a1b0c204004324150cc3b5e3c4f310c9049f", size = 1477934, upload-time = "2026-03-09T13:13:27.166Z" }, + { url = "https://files.pythonhosted.org/packages/c8/2f/cebfcdb60fd6a9b0f6b47a9337198bcbad6fbe15e68189b7011fd914911f/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b2af221f268f5af85e776a73d62b0845fc8baf8ef0abfae79d29c77d0e776aaf", size = 1278537, upload-time = "2026-03-09T13:13:28.707Z" }, + { url = "https://files.pythonhosted.org/packages/f2/0d/9b782923aada3fafb1d6b84e13121954515c669b18af0c26e7d21f579855/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b0f172dc8ffaccb8522d7c5d899de00133f2f1ca7b0a49b7da98e901de87bf2d", size = 1296685, upload-time = "2026-03-09T13:13:30.528Z" }, + { url = "https://files.pythonhosted.org/packages/27/70/83241b6634b04fe44e892688d5208332bde130f38e610c0418f9ede47ded/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6ab8ba9152203feec73758dad83af9a0bbe05001eb4639e547207c40cfb52083", size = 1346024, upload-time = "2026-03-09T13:13:32.818Z" }, + { url = "https://files.pythonhosted.org/packages/e4/db/30ed226fb271ae1a6431fc0fe0edffb2efe23cadb01e798caeb9f2ceae8f/kiwisolver-1.5.0-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:cdee07c4d7f6d72008d3f73b9bf027f4e11550224c7c50d8df1ae4a37c1402a6", size = 987241, upload-time = "2026-03-09T13:13:34.435Z" }, + { url = "https://files.pythonhosted.org/packages/ec/bd/c314595208e4c9587652d50959ead9e461995389664e490f4dce7ff0f782/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7c60d3c9b06fb23bd9c6139281ccbdc384297579ae037f08ae90c69f6845c0b1", size = 2227742, upload-time = "2026-03-09T13:13:36.4Z" }, + { url = "https://files.pythonhosted.org/packages/c1/43/0499cec932d935229b5543d073c2b87c9c22846aab48881e9d8d6e742a2d/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e315e5ec90d88e140f57696ff85b484ff68bb311e36f2c414aa4286293e6dee0", size = 2323966, upload-time = "2026-03-09T13:13:38.204Z" }, + { url = "https://files.pythonhosted.org/packages/3d/6f/79b0d760907965acfd9d61826a3d41f8f093c538f55cd2633d3f0db269f6/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:1465387ac63576c3e125e5337a6892b9e99e0627d52317f3ca79e6930d889d15", size = 1977417, upload-time = "2026-03-09T13:13:39.966Z" }, + { url = "https://files.pythonhosted.org/packages/ab/31/01d0537c41cb75a551a438c3c7a80d0c60d60b81f694dac83dd436aec0d0/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:530a3fd64c87cffa844d4b6b9768774763d9caa299e9b75d8eca6a4423b31314", size = 2491238, upload-time = "2026-03-09T13:13:41.698Z" }, + { url = "https://files.pythonhosted.org/packages/e4/34/8aefdd0be9cfd00a44509251ba864f5caf2991e36772e61c408007e7f417/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1d9daea4ea6b9be74fe2f01f7fbade8d6ffab263e781274cffca0dba9be9eec9", size = 2294947, upload-time = "2026-03-09T13:13:43.343Z" }, + { url = "https://files.pythonhosted.org/packages/ad/cf/0348374369ca588f8fe9c338fae49fa4e16eeb10ffb3d012f23a54578a9e/kiwisolver-1.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:f18c2d9782259a6dc132fdc7a63c168cbc74b35284b6d75c673958982a378384", size = 73569, upload-time = "2026-03-09T13:13:45.792Z" }, + { url = "https://files.pythonhosted.org/packages/28/26/192b26196e2316e2bd29deef67e37cdf9870d9af8e085e521afff0fed526/kiwisolver-1.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:f7c7553b13f69c1b29a5bde08ddc6d9d0c8bfb84f9ed01c30db25944aeb852a7", size = 64997, upload-time = "2026-03-09T13:13:46.878Z" }, + { url = "https://files.pythonhosted.org/packages/9d/69/024d6711d5ba575aa65d5538042e99964104e97fa153a9f10bc369182bc2/kiwisolver-1.5.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:fd40bb9cd0891c4c3cb1ddf83f8bbfa15731a248fdc8162669405451e2724b09", size = 123166, upload-time = "2026-03-09T13:13:48.032Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/adbb40df306f587054a348831220812b9b1d787aff714cfbc8556e38fccd/kiwisolver-1.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c0e1403fd7c26d77c1f03e096dc58a5c726503fa0db0456678b8668f76f521e3", size = 66395, upload-time = "2026-03-09T13:13:49.365Z" }, + { url = "https://files.pythonhosted.org/packages/a8/3a/d0a972b34e1c63e2409413104216cd1caa02c5a37cb668d1687d466c1c45/kiwisolver-1.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:dda366d548e89a90d88a86c692377d18d8bd64b39c1fb2b92cb31370e2896bbd", size = 64065, upload-time = "2026-03-09T13:13:50.562Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0a/7b98e1e119878a27ba8618ca1e18b14f992ff1eda40f47bccccf4de44121/kiwisolver-1.5.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:332b4f0145c30b5f5ad9374881133e5aa64320428a57c2c2b61e9d891a51c2f3", size = 1477903, upload-time = "2026-03-09T13:13:52.084Z" }, + { url = "https://files.pythonhosted.org/packages/18/d8/55638d89ffd27799d5cc3d8aa28e12f4ce7a64d67b285114dbedc8ea4136/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c50b89ffd3e1a911c69a1dd3de7173c0cd10b130f56222e57898683841e4f96", size = 1278751, upload-time = "2026-03-09T13:13:54.673Z" }, + { url = "https://files.pythonhosted.org/packages/b8/97/b4c8d0d18421ecceba20ad8701358453b88e32414e6f6950b5a4bad54e65/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4db576bb8c3ef9365f8b40fe0f671644de6736ae2c27a2c62d7d8a1b4329f099", size = 1296793, upload-time = "2026-03-09T13:13:56.287Z" }, + { url = "https://files.pythonhosted.org/packages/c4/10/f862f94b6389d8957448ec9df59450b81bec4abb318805375c401a1e6892/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0b85aad90cea8ac6797a53b5d5f2e967334fa4d1149f031c4537569972596cb8", size = 1346041, upload-time = "2026-03-09T13:13:58.269Z" }, + { url = "https://files.pythonhosted.org/packages/a3/6a/f1650af35821eaf09de398ec0bc2aefc8f211f0cda50204c9f1673741ba9/kiwisolver-1.5.0-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:d36ca54cb4c6c4686f7cbb7b817f66f5911c12ddb519450bbe86707155028f87", size = 987292, upload-time = "2026-03-09T13:13:59.871Z" }, + { url = "https://files.pythonhosted.org/packages/de/19/d7fb82984b9238115fe629c915007be608ebd23dc8629703d917dbfaffd4/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:38f4a703656f493b0ad185211ccfca7f0386120f022066b018eb5296d8613e23", size = 2227865, upload-time = "2026-03-09T13:14:01.401Z" }, + { url = "https://files.pythonhosted.org/packages/7f/b9/46b7f386589fd222dac9e9de9c956ce5bcefe2ee73b4e79891381dda8654/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3ac2360e93cb41be81121755c6462cff3beaa9967188c866e5fce5cf13170859", size = 2324369, upload-time = "2026-03-09T13:14:02.972Z" }, + { url = "https://files.pythonhosted.org/packages/92/8b/95e237cf3d9c642960153c769ddcbe278f182c8affb20cecc1cc983e7cc5/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c95cab08d1965db3d84a121f1c7ce7479bdd4072c9b3dafd8fecce48a2e6b902", size = 1977989, upload-time = "2026-03-09T13:14:04.503Z" }, + { url = "https://files.pythonhosted.org/packages/1b/95/980c9df53501892784997820136c01f62bc1865e31b82b9560f980c0e649/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fc20894c3d21194d8041a28b65622d5b86db786da6e3cfe73f0c762951a61167", size = 2491645, upload-time = "2026-03-09T13:14:06.106Z" }, + { url = "https://files.pythonhosted.org/packages/cb/32/900647fd0840abebe1561792c6b31e6a7c0e278fc3973d30572a965ca14c/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7a32f72973f0f950c1920475d5c5ea3d971b81b6f0ec53b8d0a956cc965f22e0", size = 2295237, upload-time = "2026-03-09T13:14:08.891Z" }, + { url = "https://files.pythonhosted.org/packages/be/8a/be60e3bbcf513cc5a50f4a3e88e1dcecebb79c1ad607a7222877becaa101/kiwisolver-1.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:0bf3acf1419fa93064a4c2189ac0b58e3be7872bf6ee6177b0d4c63dc4cea276", size = 73573, upload-time = "2026-03-09T13:14:12.327Z" }, + { url = "https://files.pythonhosted.org/packages/4d/d2/64be2e429eb4fca7f7e1c52a91b12663aeaf25de3895e5cca0f47ef2a8d0/kiwisolver-1.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:fa8eb9ecdb7efb0b226acec134e0d709e87a909fa4971a54c0c4f6e88635484c", size = 64998, upload-time = "2026-03-09T13:14:13.469Z" }, + { url = "https://files.pythonhosted.org/packages/b0/69/ce68dd0c85755ae2de490bf015b62f2cea5f6b14ff00a463f9d0774449ff/kiwisolver-1.5.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:db485b3847d182b908b483b2ed133c66d88d49cacf98fd278fadafe11b4478d1", size = 125700, upload-time = "2026-03-09T13:14:14.636Z" }, + { url = "https://files.pythonhosted.org/packages/74/aa/937aac021cf9d4349990d47eb319309a51355ed1dbdc9c077cdc9224cb11/kiwisolver-1.5.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:be12f931839a3bdfe28b584db0e640a65a8bcbc24560ae3fdb025a449b3d754e", size = 67537, upload-time = "2026-03-09T13:14:15.808Z" }, + { url = "https://files.pythonhosted.org/packages/ee/20/3a87fbece2c40ad0f6f0aefa93542559159c5f99831d596050e8afae7a9f/kiwisolver-1.5.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:16b85d37c2cbb3253226d26e64663f755d88a03439a9c47df6246b35defbdfb7", size = 65514, upload-time = "2026-03-09T13:14:18.035Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7f/f943879cda9007c45e1f7dba216d705c3a18d6b35830e488b6c6a4e7cdf0/kiwisolver-1.5.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4432b835675f0ea7414aab3d37d119f7226d24869b7a829caeab49ebda407b0c", size = 1584848, upload-time = "2026-03-09T13:14:19.745Z" }, + { url = "https://files.pythonhosted.org/packages/37/f8/4d4f85cc1870c127c88d950913370dd76138482161cd07eabbc450deff01/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b0feb50971481a2cc44d94e88bdb02cdd497618252ae226b8eb1201b957e368", size = 1391542, upload-time = "2026-03-09T13:14:21.54Z" }, + { url = "https://files.pythonhosted.org/packages/04/0b/65dd2916c84d252b244bd405303220f729e7c17c9d7d33dca6feeff9ffc4/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56fa888f10d0f367155e76ce849fa1166fc9730d13bd2d65a2aa13b6f5424489", size = 1404447, upload-time = "2026-03-09T13:14:23.205Z" }, + { url = "https://files.pythonhosted.org/packages/39/5c/2606a373247babce9b1d056c03a04b65f3cf5290a8eac5d7bdead0a17e21/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:940dda65d5e764406b9fb92761cbf462e4e63f712ab60ed98f70552e496f3bf1", size = 1455918, upload-time = "2026-03-09T13:14:24.74Z" }, + { url = "https://files.pythonhosted.org/packages/d5/d1/c6078b5756670658e9192a2ef11e939c92918833d2745f85cd14a6004bdf/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_39_riscv64.whl", hash = "sha256:89fc958c702ee9a745e4700378f5d23fddbc46ff89e8fdbf5395c24d5c1452a3", size = 1072856, upload-time = "2026-03-09T13:14:26.597Z" }, + { url = "https://files.pythonhosted.org/packages/cb/c8/7def6ddf16eb2b3741d8b172bdaa9af882b03c78e9b0772975408801fa63/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9027d773c4ff81487181a925945743413f6069634d0b122d0b37684ccf4f1e18", size = 2333580, upload-time = "2026-03-09T13:14:28.237Z" }, + { url = "https://files.pythonhosted.org/packages/9e/87/2ac1fce0eb1e616fcd3c35caa23e665e9b1948bb984f4764790924594128/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:5b233ea3e165e43e35dba1d2b8ecc21cf070b45b65ae17dd2747d2713d942021", size = 2423018, upload-time = "2026-03-09T13:14:30.018Z" }, + { url = "https://files.pythonhosted.org/packages/67/13/c6700ccc6cc218716bfcda4935e4b2997039869b4ad8a94f364c5a3b8e63/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ce9bf03dad3b46408c08649c6fbd6ca28a9fce0eb32fdfffa6775a13103b5310", size = 2062804, upload-time = "2026-03-09T13:14:32.888Z" }, + { url = "https://files.pythonhosted.org/packages/1b/bd/877056304626943ff0f1f44c08f584300c199b887cb3176cd7e34f1515f1/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:fc4d3f1fb9ca0ae9f97b095963bc6326f1dbfd3779d6679a1e016b9baaa153d3", size = 2597482, upload-time = "2026-03-09T13:14:34.971Z" }, + { url = "https://files.pythonhosted.org/packages/75/19/c60626c47bf0f8ac5dcf72c6c98e266d714f2fbbfd50cf6dab5ede3aaa50/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f443b4825c50a51ee68585522ab4a1d1257fac65896f282b4c6763337ac9f5d2", size = 2394328, upload-time = "2026-03-09T13:14:36.816Z" }, + { url = "https://files.pythonhosted.org/packages/47/84/6a6d5e5bb8273756c27b7d810d47f7ef2f1f9b9fd23c9ee9a3f8c75c9cef/kiwisolver-1.5.0-cp313-cp313t-win_arm64.whl", hash = "sha256:893ff3a711d1b515ba9da14ee090519bad4610ed1962fbe298a434e8c5f8db53", size = 68410, upload-time = "2026-03-09T13:14:38.695Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/060f45052f2a01ad5762c8fdecd6d7a752b43400dc29ff75cd47225a40fd/kiwisolver-1.5.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8df31fe574b8b3993cc61764f40941111b25c2d9fea13d3ce24a49907cd2d615", size = 123231, upload-time = "2026-03-09T13:14:41.323Z" }, + { url = "https://files.pythonhosted.org/packages/c2/a7/78da680eadd06ff35edef6ef68a1ad273bad3e2a0936c9a885103230aece/kiwisolver-1.5.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1d49a49ac4cbfb7c1375301cd1ec90169dfeae55ff84710d782260ce77a75a02", size = 66489, upload-time = "2026-03-09T13:14:42.534Z" }, + { url = "https://files.pythonhosted.org/packages/49/b2/97980f3ad4fae37dd7fe31626e2bf75fbf8bdf5d303950ec1fab39a12da8/kiwisolver-1.5.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0cbe94b69b819209a62cb27bdfa5dc2a8977d8de2f89dfd97ba4f53ed3af754e", size = 64063, upload-time = "2026-03-09T13:14:44.759Z" }, + { url = "https://files.pythonhosted.org/packages/e7/f9/b06c934a6aa8bc91f566bd2a214fd04c30506c2d9e2b6b171953216a65b6/kiwisolver-1.5.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:80aa065ffd378ff784822a6d7c3212f2d5f5e9c3589614b5c228b311fd3063ac", size = 1475913, upload-time = "2026-03-09T13:14:46.247Z" }, + { url = "https://files.pythonhosted.org/packages/6b/f0/f768ae564a710135630672981231320bc403cf9152b5596ec5289de0f106/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e7f886f47ab881692f278ae901039a234e4025a68e6dfab514263a0b1c4ae05", size = 1282782, upload-time = "2026-03-09T13:14:48.458Z" }, + { url = "https://files.pythonhosted.org/packages/e2/9f/1de7aad00697325f05238a5f2eafbd487fb637cc27a558b5367a5f37fb7f/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5060731cc3ed12ca3a8b57acd4aeca5bbc2f49216dd0bec1650a1acd89486bcd", size = 1300815, upload-time = "2026-03-09T13:14:50.721Z" }, + { url = "https://files.pythonhosted.org/packages/5a/c2/297f25141d2e468e0ce7f7a7b92e0cf8918143a0cbd3422c1ad627e85a06/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7a4aa69609f40fce3cbc3f87b2061f042eee32f94b8f11db707b66a26461591a", size = 1347925, upload-time = "2026-03-09T13:14:52.304Z" }, + { url = "https://files.pythonhosted.org/packages/b9/d3/f4c73a02eb41520c47610207b21afa8cdd18fdbf64ffd94674ae21c4812d/kiwisolver-1.5.0-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:d168fda2dbff7b9b5f38e693182d792a938c31db4dac3a80a4888de603c99554", size = 991322, upload-time = "2026-03-09T13:14:54.637Z" }, + { url = "https://files.pythonhosted.org/packages/7b/46/d3f2efef7732fcda98d22bf4ad5d3d71d545167a852ca710a494f4c15343/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:413b820229730d358efd838ecbab79902fe97094565fdc80ddb6b0a18c18a581", size = 2232857, upload-time = "2026-03-09T13:14:56.471Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ec/2d9756bf2b6d26ae4349b8d3662fb3993f16d80c1f971c179ce862b9dbae/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5124d1ea754509b09e53738ec185584cc609aae4a3b510aaf4ed6aa047ef9303", size = 2329376, upload-time = "2026-03-09T13:14:58.072Z" }, + { url = "https://files.pythonhosted.org/packages/8f/9f/876a0a0f2260f1bde92e002b3019a5fabc35e0939c7d945e0fa66185eb20/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e4415a8db000bf49a6dd1c478bf70062eaacff0f462b92b0ba68791a905861f9", size = 1982549, upload-time = "2026-03-09T13:14:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/6c/4f/ba3624dfac23a64d54ac4179832860cb537c1b0af06024936e82ca4154a0/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d618fd27420381a4f6044faa71f46d8bfd911bd077c555f7138ed88729bfbe79", size = 2494680, upload-time = "2026-03-09T13:15:01.364Z" }, + { url = "https://files.pythonhosted.org/packages/39/b7/97716b190ab98911b20d10bf92eca469121ec483b8ce0edd314f51bc85af/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5092eb5b1172947f57d6ea7d89b2f29650414e4293c47707eb499ec07a0ac796", size = 2297905, upload-time = "2026-03-09T13:15:03.925Z" }, + { url = "https://files.pythonhosted.org/packages/a3/36/4e551e8aa55c9188bca9abb5096805edbf7431072b76e2298e34fd3a3008/kiwisolver-1.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:d76e2d8c75051d58177e762164d2e9ab92886534e3a12e795f103524f221dd8e", size = 75086, upload-time = "2026-03-09T13:15:07.775Z" }, + { url = "https://files.pythonhosted.org/packages/70/15/9b90f7df0e31a003c71649cf66ef61c3c1b862f48c81007fa2383c8bd8d7/kiwisolver-1.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:fa6248cd194edff41d7ea9425ced8ca3a6f838bfb295f6f1d6e6bb694a8518df", size = 66577, upload-time = "2026-03-09T13:15:09.139Z" }, + { url = "https://files.pythonhosted.org/packages/17/01/7dc8c5443ff42b38e72731643ed7cf1ed9bf01691ae5cdca98501999ed83/kiwisolver-1.5.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:d1ffeb80b5676463d7a7d56acbe8e37a20ce725570e09549fe738e02ca6b7e1e", size = 125794, upload-time = "2026-03-09T13:15:10.525Z" }, + { url = "https://files.pythonhosted.org/packages/46/8a/b4ebe46ebaac6a303417fab10c2e165c557ddaff558f9699d302b256bc53/kiwisolver-1.5.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bc4d8e252f532ab46a1de9349e2d27b91fce46736a9eedaa37beaca66f574ed4", size = 67646, upload-time = "2026-03-09T13:15:12.016Z" }, + { url = "https://files.pythonhosted.org/packages/60/35/10a844afc5f19d6f567359bf4789e26661755a2f36200d5d1ed8ad0126e5/kiwisolver-1.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6783e069732715ad0c3ce96dbf21dbc2235ab0593f2baf6338101f70371f4028", size = 65511, upload-time = "2026-03-09T13:15:13.311Z" }, + { url = "https://files.pythonhosted.org/packages/f8/8a/685b297052dd041dcebce8e8787b58923b6e78acc6115a0dc9189011c44b/kiwisolver-1.5.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e7c4c09a490dc4d4a7f8cbee56c606a320f9dc28cf92a7157a39d1ce7676a657", size = 1584858, upload-time = "2026-03-09T13:15:15.103Z" }, + { url = "https://files.pythonhosted.org/packages/9e/80/04865e3d4638ac5bddec28908916df4a3075b8c6cc101786a96803188b96/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a075bd7bd19c70cf67c8badfa36cf7c5d8de3c9ddb8420c51e10d9c50e94920", size = 1392539, upload-time = "2026-03-09T13:15:16.661Z" }, + { url = "https://files.pythonhosted.org/packages/ba/01/77a19cacc0893fa13fafa46d1bba06fb4dc2360b3292baf4b56d8e067b24/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bdd3e53429ff02aa319ba59dfe4ceeec345bf46cf180ec2cf6fd5b942e7975e9", size = 1405310, upload-time = "2026-03-09T13:15:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/53/39/bcaf5d0cca50e604cfa9b4e3ae1d64b50ca1ae5b754122396084599ef903/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cdcb35dc9d807259c981a85531048ede628eabcffb3239adf3d17463518992d", size = 1456244, upload-time = "2026-03-09T13:15:20.444Z" }, + { url = "https://files.pythonhosted.org/packages/d0/7a/72c187abc6975f6978c3e39b7cf67aeb8b3c0a8f9790aa7fd412855e9e1f/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:70d593af6a6ca332d1df73d519fddb5148edb15cd90d5f0155e3746a6d4fcc65", size = 1073154, upload-time = "2026-03-09T13:15:22.039Z" }, + { url = "https://files.pythonhosted.org/packages/c7/ca/cf5b25783ebbd59143b4371ed0c8428a278abe68d6d0104b01865b1bbd0f/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:377815a8616074cabbf3f53354e1d040c35815a134e01d7614b7692e4bf8acfa", size = 2334377, upload-time = "2026-03-09T13:15:23.741Z" }, + { url = "https://files.pythonhosted.org/packages/4a/e5/b1f492adc516796e88751282276745340e2a72dcd0d36cf7173e0daf3210/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0255a027391d52944eae1dbb5d4cc5903f57092f3674e8e544cdd2622826b3f0", size = 2425288, upload-time = "2026-03-09T13:15:25.789Z" }, + { url = "https://files.pythonhosted.org/packages/e6/e5/9b21fbe91a61b8f409d74a26498706e97a48008bfcd1864373d32a6ba31c/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:012b1eb16e28718fa782b5e61dc6f2da1f0792ca73bd05d54de6cb9561665fc9", size = 2063158, upload-time = "2026-03-09T13:15:27.63Z" }, + { url = "https://files.pythonhosted.org/packages/b1/02/83f47986138310f95ea95531f851b2a62227c11cbc3e690ae1374fe49f0f/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0e3aafb33aed7479377e5e9a82e9d4bf87063741fc99fc7ae48b0f16e32bdd6f", size = 2597260, upload-time = "2026-03-09T13:15:29.421Z" }, + { url = "https://files.pythonhosted.org/packages/07/18/43a5f24608d8c313dd189cf838c8e68d75b115567c6279de7796197cfb6a/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e7a116ae737f0000343218c4edf5bd45893bfeaff0993c0b215d7124c9f77646", size = 2394403, upload-time = "2026-03-09T13:15:31.517Z" }, + { url = "https://files.pythonhosted.org/packages/3b/b5/98222136d839b8afabcaa943b09bd05888c2d36355b7e448550211d1fca4/kiwisolver-1.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:1dd9b0b119a350976a6d781e7278ec7aca0b201e1a9e2d23d9804afecb6ca681", size = 79687, upload-time = "2026-03-09T13:15:33.204Z" }, + { url = "https://files.pythonhosted.org/packages/99/a2/ca7dc962848040befed12732dff6acae7fb3c4f6fc4272b3f6c9a30b8713/kiwisolver-1.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:58f812017cd2985c21fbffb4864d59174d4903dd66fa23815e74bbc7a0e2dd57", size = 70032, upload-time = "2026-03-09T13:15:34.411Z" }, + { url = "https://files.pythonhosted.org/packages/1c/fa/2910df836372d8761bb6eff7d8bdcb1613b5c2e03f260efe7abe34d388a7/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-macosx_10_13_x86_64.whl", hash = "sha256:5ae8e62c147495b01a0f4765c878e9bfdf843412446a247e28df59936e99e797", size = 130262, upload-time = "2026-03-09T13:15:35.629Z" }, + { url = "https://files.pythonhosted.org/packages/0f/41/c5f71f9f00aabcc71fee8b7475e3f64747282580c2fe748961ba29b18385/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:f6764a4ccab3078db14a632420930f6186058750df066b8ea2a7106df91d3203", size = 138036, upload-time = "2026-03-09T13:15:36.894Z" }, + { url = "https://files.pythonhosted.org/packages/fa/06/7399a607f434119c6e1fdc8ec89a8d51ccccadf3341dee4ead6bd14caaf5/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c31c13da98624f957b0fb1b5bae5383b2333c2c3f6793d9825dd5ce79b525cb7", size = 194295, upload-time = "2026-03-09T13:15:38.22Z" }, + { url = "https://files.pythonhosted.org/packages/b5/91/53255615acd2a1eaca307ede3c90eb550bae9c94581f8c00081b6b1c8f44/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-win_amd64.whl", hash = "sha256:1f1489f769582498610e015a8ef2d36f28f505ab3096d0e16b4858a9ec214f57", size = 75987, upload-time = "2026-03-09T13:15:39.65Z" }, + { url = "https://files.pythonhosted.org/packages/17/6f/6fd4f690a40c2582fa34b97d2678f718acf3706b91d270c65ecb455d0a06/kiwisolver-1.5.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:295d9ffe712caa9f8a3081de8d32fc60191b4b51c76f02f951fd8407253528f4", size = 59606, upload-time = "2026-03-09T13:15:40.81Z" }, + { url = "https://files.pythonhosted.org/packages/82/a0/2355d5e3b338f13ce63f361abb181e3b6ea5fffdb73f739b3e80efa76159/kiwisolver-1.5.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:51e8c4084897de9f05898c2c2a39af6318044ae969d46ff7a34ed3f96274adca", size = 57537, upload-time = "2026-03-09T13:15:42.071Z" }, + { url = "https://files.pythonhosted.org/packages/c8/b9/1d50e610ecadebe205b71d6728fd224ce0e0ca6aba7b9cbe1da049203ac5/kiwisolver-1.5.0-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b83af57bdddef03c01a9138034c6ff03181a3028d9a1003b301eb1a55e161a3f", size = 79888, upload-time = "2026-03-09T13:15:43.317Z" }, + { url = "https://files.pythonhosted.org/packages/cd/ee/b85ffcd75afed0357d74f0e6fc02a4507da441165de1ca4760b9f496390d/kiwisolver-1.5.0-pp310-pypy310_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf4679a3d71012a7c2bf360e5cd878fbd5e4fcac0896b56393dec239d81529ed", size = 77584, upload-time = "2026-03-09T13:15:44.605Z" }, + { url = "https://files.pythonhosted.org/packages/6b/dd/644d0dde6010a8583b4cd66dd41c5f83f5325464d15c4f490b3340ab73b4/kiwisolver-1.5.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:41024ed50e44ab1a60d3fe0a9d15a4ccc9f5f2b1d814ff283c8d01134d5b81bc", size = 73390, upload-time = "2026-03-09T13:15:45.832Z" }, + { url = "https://files.pythonhosted.org/packages/e9/eb/5fcbbbf9a0e2c3a35effb88831a483345326bbc3a030a3b5b69aee647f84/kiwisolver-1.5.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ec4c85dc4b687c7f7f15f553ff26a98bfe8c58f5f7f0ac8905f0ba4c7be60232", size = 59532, upload-time = "2026-03-09T13:15:47.047Z" }, + { url = "https://files.pythonhosted.org/packages/c3/9b/e17104555bb4db148fd52327feea1e96be4b88e8e008b029002c281a21ab/kiwisolver-1.5.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:12e91c215a96e39f57989c8912ae761286ac5a9584d04030ceb3368a357f017a", size = 57420, upload-time = "2026-03-09T13:15:48.199Z" }, + { url = "https://files.pythonhosted.org/packages/48/44/2b5b95b7aa39fb2d8d9d956e0f3d5d45aef2ae1d942d4c3ffac2f9cfed1a/kiwisolver-1.5.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:be4a51a55833dc29ab5d7503e7bcb3b3af3402d266018137127450005cdfe737", size = 79892, upload-time = "2026-03-09T13:15:49.694Z" }, + { url = "https://files.pythonhosted.org/packages/52/7d/7157f9bba6b455cfb4632ed411e199fc8b8977642c2b12082e1bd9e6d173/kiwisolver-1.5.0-pp311-pypy311_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:daae526907e262de627d8f70058a0f64acc9e2641c164c99c8f594b34a799a16", size = 77603, upload-time = "2026-03-09T13:15:50.945Z" }, + { url = "https://files.pythonhosted.org/packages/0a/dd/8050c947d435c8d4bc94e3252f4d8bb8a76cfb424f043a8680be637a57f1/kiwisolver-1.5.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:59cd8683f575d96df5bb48f6add94afc055012c29e28124fcae2b63661b9efb1", size = 73558, upload-time = "2026-03-09T13:15:52.112Z" }, +] + [[package]] name = "lark" version = "1.3.1" @@ -1020,6 +1367,81 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, ] +[[package]] +name = "matplotlib" +version = "3.10.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "contourpy", version = "1.3.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "contourpy", version = "1.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "cycler" }, + { name = "fonttools" }, + { name = "kiwisolver" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.4.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "packaging" }, + { name = "pillow" }, + { name = "pyparsing" }, + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8a/76/d3c6e3a13fe484ebe7718d14e269c9569c4eb0020a968a327acb3b9a8fe6/matplotlib-3.10.8.tar.gz", hash = "sha256:2299372c19d56bcd35cf05a2738308758d32b9eaed2371898d8f5bd33f084aa3", size = 34806269, upload-time = "2025-12-10T22:56:51.155Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/be/a30bd917018ad220c400169fba298f2bb7003c8ccbc0c3e24ae2aacad1e8/matplotlib-3.10.8-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:00270d217d6b20d14b584c521f810d60c5c78406dc289859776550df837dcda7", size = 8239828, upload-time = "2025-12-10T22:55:02.313Z" }, + { url = "https://files.pythonhosted.org/packages/58/27/ca01e043c4841078e82cf6e80a6993dfecd315c3d79f5f3153afbb8e1ec6/matplotlib-3.10.8-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:37b3c1cc42aa184b3f738cfa18c1c1d72fd496d85467a6cf7b807936d39aa656", size = 8128050, upload-time = "2025-12-10T22:55:04.997Z" }, + { url = "https://files.pythonhosted.org/packages/cb/aa/7ab67f2b729ae6a91bcf9dcac0affb95fb8c56f7fd2b2af894ae0b0cf6fa/matplotlib-3.10.8-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ee40c27c795bda6a5292e9cff9890189d32f7e3a0bf04e0e3c9430c4a00c37df", size = 8700452, upload-time = "2025-12-10T22:55:07.47Z" }, + { url = "https://files.pythonhosted.org/packages/73/ae/2d5817b0acee3c49b7e7ccfbf5b273f284957cc8e270adf36375db353190/matplotlib-3.10.8-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a48f2b74020919552ea25d222d5cc6af9ca3f4eb43a93e14d068457f545c2a17", size = 9534928, upload-time = "2025-12-10T22:55:10.566Z" }, + { url = "https://files.pythonhosted.org/packages/c9/5b/8e66653e9f7c39cb2e5cab25fce4810daffa2bff02cbf5f3077cea9e942c/matplotlib-3.10.8-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f254d118d14a7f99d616271d6c3c27922c092dac11112670b157798b89bf4933", size = 9586377, upload-time = "2025-12-10T22:55:12.362Z" }, + { url = "https://files.pythonhosted.org/packages/e2/e2/fd0bbadf837f81edb0d208ba8f8cb552874c3b16e27cb91a31977d90875d/matplotlib-3.10.8-cp310-cp310-win_amd64.whl", hash = "sha256:f9b587c9c7274c1613a30afabf65a272114cd6cdbe67b3406f818c79d7ab2e2a", size = 8128127, upload-time = "2025-12-10T22:55:14.436Z" }, + { url = "https://files.pythonhosted.org/packages/f8/86/de7e3a1cdcfc941483af70609edc06b83e7c8a0e0dc9ac325200a3f4d220/matplotlib-3.10.8-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:6be43b667360fef5c754dda5d25a32e6307a03c204f3c0fc5468b78fa87b4160", size = 8251215, upload-time = "2025-12-10T22:55:16.175Z" }, + { url = "https://files.pythonhosted.org/packages/fd/14/baad3222f424b19ce6ad243c71de1ad9ec6b2e4eb1e458a48fdc6d120401/matplotlib-3.10.8-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a2b336e2d91a3d7006864e0990c83b216fcdca64b5a6484912902cef87313d78", size = 8139625, upload-time = "2025-12-10T22:55:17.712Z" }, + { url = "https://files.pythonhosted.org/packages/8f/a0/7024215e95d456de5883e6732e708d8187d9753a21d32f8ddb3befc0c445/matplotlib-3.10.8-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:efb30e3baaea72ce5928e32bab719ab4770099079d66726a62b11b1ef7273be4", size = 8712614, upload-time = "2025-12-10T22:55:20.8Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f4/b8347351da9a5b3f41e26cf547252d861f685c6867d179a7c9d60ad50189/matplotlib-3.10.8-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d56a1efd5bfd61486c8bc968fa18734464556f0fb8e51690f4ac25d85cbbbbc2", size = 9540997, upload-time = "2025-12-10T22:55:23.258Z" }, + { url = "https://files.pythonhosted.org/packages/9e/c0/c7b914e297efe0bc36917bf216b2acb91044b91e930e878ae12981e461e5/matplotlib-3.10.8-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:238b7ce5717600615c895050239ec955d91f321c209dd110db988500558e70d6", size = 9596825, upload-time = "2025-12-10T22:55:25.217Z" }, + { url = "https://files.pythonhosted.org/packages/6f/d3/a4bbc01c237ab710a1f22b4da72f4ff6d77eb4c7735ea9811a94ae239067/matplotlib-3.10.8-cp311-cp311-win_amd64.whl", hash = "sha256:18821ace09c763ec93aef5eeff087ee493a24051936d7b9ebcad9662f66501f9", size = 8135090, upload-time = "2025-12-10T22:55:27.162Z" }, + { url = "https://files.pythonhosted.org/packages/89/dd/a0b6588f102beab33ca6f5218b31725216577b2a24172f327eaf6417d5c9/matplotlib-3.10.8-cp311-cp311-win_arm64.whl", hash = "sha256:bab485bcf8b1c7d2060b4fcb6fc368a9e6f4cd754c9c2fea281f4be21df394a2", size = 8012377, upload-time = "2025-12-10T22:55:29.185Z" }, + { url = "https://files.pythonhosted.org/packages/9e/67/f997cdcbb514012eb0d10cd2b4b332667997fb5ebe26b8d41d04962fa0e6/matplotlib-3.10.8-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:64fcc24778ca0404ce0cb7b6b77ae1f4c7231cdd60e6778f999ee05cbd581b9a", size = 8260453, upload-time = "2025-12-10T22:55:30.709Z" }, + { url = "https://files.pythonhosted.org/packages/7e/65/07d5f5c7f7c994f12c768708bd2e17a4f01a2b0f44a1c9eccad872433e2e/matplotlib-3.10.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b9a5ca4ac220a0cdd1ba6bcba3608547117d30468fefce49bb26f55c1a3d5c58", size = 8148321, upload-time = "2025-12-10T22:55:33.265Z" }, + { url = "https://files.pythonhosted.org/packages/3e/f3/c5195b1ae57ef85339fd7285dfb603b22c8b4e79114bae5f4f0fcf688677/matplotlib-3.10.8-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3ab4aabc72de4ff77b3ec33a6d78a68227bf1123465887f9905ba79184a1cc04", size = 8716944, upload-time = "2025-12-10T22:55:34.922Z" }, + { url = "https://files.pythonhosted.org/packages/00/f9/7638f5cc82ec8a7aa005de48622eecc3ed7c9854b96ba15bd76b7fd27574/matplotlib-3.10.8-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:24d50994d8c5816ddc35411e50a86ab05f575e2530c02752e02538122613371f", size = 9550099, upload-time = "2025-12-10T22:55:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/57/61/78cd5920d35b29fd2a0fe894de8adf672ff52939d2e9b43cb83cd5ce1bc7/matplotlib-3.10.8-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:99eefd13c0dc3b3c1b4d561c1169e65fe47aab7b8158754d7c084088e2329466", size = 9613040, upload-time = "2025-12-10T22:55:38.715Z" }, + { url = "https://files.pythonhosted.org/packages/30/4e/c10f171b6e2f44d9e3a2b96efa38b1677439d79c99357600a62cc1e9594e/matplotlib-3.10.8-cp312-cp312-win_amd64.whl", hash = "sha256:dd80ecb295460a5d9d260df63c43f4afbdd832d725a531f008dad1664f458adf", size = 8142717, upload-time = "2025-12-10T22:55:41.103Z" }, + { url = "https://files.pythonhosted.org/packages/f1/76/934db220026b5fef85f45d51a738b91dea7d70207581063cd9bd8fafcf74/matplotlib-3.10.8-cp312-cp312-win_arm64.whl", hash = "sha256:3c624e43ed56313651bc18a47f838b60d7b8032ed348911c54906b130b20071b", size = 8012751, upload-time = "2025-12-10T22:55:42.684Z" }, + { url = "https://files.pythonhosted.org/packages/3d/b9/15fd5541ef4f5b9a17eefd379356cf12175fe577424e7b1d80676516031a/matplotlib-3.10.8-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3f2e409836d7f5ac2f1c013110a4d50b9f7edc26328c108915f9075d7d7a91b6", size = 8261076, upload-time = "2025-12-10T22:55:44.648Z" }, + { url = "https://files.pythonhosted.org/packages/8d/a0/2ba3473c1b66b9c74dc7107c67e9008cb1782edbe896d4c899d39ae9cf78/matplotlib-3.10.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56271f3dac49a88d7fca5060f004d9d22b865f743a12a23b1e937a0be4818ee1", size = 8148794, upload-time = "2025-12-10T22:55:46.252Z" }, + { url = "https://files.pythonhosted.org/packages/75/97/a471f1c3eb1fd6f6c24a31a5858f443891d5127e63a7788678d14e249aea/matplotlib-3.10.8-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a0a7f52498f72f13d4a25ea70f35f4cb60642b466cbb0a9be951b5bc3f45a486", size = 8718474, upload-time = "2025-12-10T22:55:47.864Z" }, + { url = "https://files.pythonhosted.org/packages/01/be/cd478f4b66f48256f42927d0acbcd63a26a893136456cd079c0cc24fbabf/matplotlib-3.10.8-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:646d95230efb9ca614a7a594d4fcacde0ac61d25e37dd51710b36477594963ce", size = 9549637, upload-time = "2025-12-10T22:55:50.048Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7c/8dc289776eae5109e268c4fb92baf870678dc048a25d4ac903683b86d5bf/matplotlib-3.10.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f89c151aab2e2e23cb3fe0acad1e8b82841fd265379c4cecd0f3fcb34c15e0f6", size = 9613678, upload-time = "2025-12-10T22:55:52.21Z" }, + { url = "https://files.pythonhosted.org/packages/64/40/37612487cc8a437d4dd261b32ca21fe2d79510fe74af74e1f42becb1bdb8/matplotlib-3.10.8-cp313-cp313-win_amd64.whl", hash = "sha256:e8ea3e2d4066083e264e75c829078f9e149fa119d27e19acd503de65e0b13149", size = 8142686, upload-time = "2025-12-10T22:55:54.253Z" }, + { url = "https://files.pythonhosted.org/packages/66/52/8d8a8730e968185514680c2a6625943f70269509c3dcfc0dcf7d75928cb8/matplotlib-3.10.8-cp313-cp313-win_arm64.whl", hash = "sha256:c108a1d6fa78a50646029cb6d49808ff0fc1330fda87fa6f6250c6b5369b6645", size = 8012917, upload-time = "2025-12-10T22:55:56.268Z" }, + { url = "https://files.pythonhosted.org/packages/b5/27/51fe26e1062f298af5ef66343d8ef460e090a27fea73036c76c35821df04/matplotlib-3.10.8-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:ad3d9833a64cf48cc4300f2b406c3d0f4f4724a91c0bd5640678a6ba7c102077", size = 8305679, upload-time = "2025-12-10T22:55:57.856Z" }, + { url = "https://files.pythonhosted.org/packages/2c/1e/4de865bc591ac8e3062e835f42dd7fe7a93168d519557837f0e37513f629/matplotlib-3.10.8-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:eb3823f11823deade26ce3b9f40dcb4a213da7a670013929f31d5f5ed1055b22", size = 8198336, upload-time = "2025-12-10T22:55:59.371Z" }, + { url = "https://files.pythonhosted.org/packages/c6/cb/2f7b6e75fb4dce87ef91f60cac4f6e34f4c145ab036a22318ec837971300/matplotlib-3.10.8-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d9050fee89a89ed57b4fb2c1bfac9a3d0c57a0d55aed95949eedbc42070fea39", size = 8731653, upload-time = "2025-12-10T22:56:01.032Z" }, + { url = "https://files.pythonhosted.org/packages/46/b3/bd9c57d6ba670a37ab31fb87ec3e8691b947134b201f881665b28cc039ff/matplotlib-3.10.8-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b44d07310e404ba95f8c25aa5536f154c0a8ec473303535949e52eb71d0a1565", size = 9561356, upload-time = "2025-12-10T22:56:02.95Z" }, + { url = "https://files.pythonhosted.org/packages/c0/3d/8b94a481456dfc9dfe6e39e93b5ab376e50998cddfd23f4ae3b431708f16/matplotlib-3.10.8-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:0a33deb84c15ede243aead39f77e990469fff93ad1521163305095b77b72ce4a", size = 9614000, upload-time = "2025-12-10T22:56:05.411Z" }, + { url = "https://files.pythonhosted.org/packages/bd/cd/bc06149fe5585ba800b189a6a654a75f1f127e8aab02fd2be10df7fa500c/matplotlib-3.10.8-cp313-cp313t-win_amd64.whl", hash = "sha256:3a48a78d2786784cc2413e57397981fb45c79e968d99656706018d6e62e57958", size = 8220043, upload-time = "2025-12-10T22:56:07.551Z" }, + { url = "https://files.pythonhosted.org/packages/e3/de/b22cf255abec916562cc04eef457c13e58a1990048de0c0c3604d082355e/matplotlib-3.10.8-cp313-cp313t-win_arm64.whl", hash = "sha256:15d30132718972c2c074cd14638c7f4592bd98719e2308bccea40e0538bc0cb5", size = 8062075, upload-time = "2025-12-10T22:56:09.178Z" }, + { url = "https://files.pythonhosted.org/packages/3c/43/9c0ff7a2f11615e516c3b058e1e6e8f9614ddeca53faca06da267c48345d/matplotlib-3.10.8-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b53285e65d4fa4c86399979e956235deb900be5baa7fc1218ea67fbfaeaadd6f", size = 8262481, upload-time = "2025-12-10T22:56:10.885Z" }, + { url = "https://files.pythonhosted.org/packages/6f/ca/e8ae28649fcdf039fda5ef554b40a95f50592a3c47e6f7270c9561c12b07/matplotlib-3.10.8-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:32f8dce744be5569bebe789e46727946041199030db8aeb2954d26013a0eb26b", size = 8151473, upload-time = "2025-12-10T22:56:12.377Z" }, + { url = "https://files.pythonhosted.org/packages/f1/6f/009d129ae70b75e88cbe7e503a12a4c0670e08ed748a902c2568909e9eb5/matplotlib-3.10.8-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4cf267add95b1c88300d96ca837833d4112756045364f5c734a2276038dae27d", size = 9553896, upload-time = "2025-12-10T22:56:14.432Z" }, + { url = "https://files.pythonhosted.org/packages/f5/26/4221a741eb97967bc1fd5e4c52b9aa5a91b2f4ec05b59f6def4d820f9df9/matplotlib-3.10.8-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2cf5bd12cecf46908f286d7838b2abc6c91cda506c0445b8223a7c19a00df008", size = 9824193, upload-time = "2025-12-10T22:56:16.29Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f3/3abf75f38605772cf48a9daf5821cd4f563472f38b4b828c6fba6fa6d06e/matplotlib-3.10.8-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:41703cc95688f2516b480f7f339d8851a6035f18e100ee6a32bc0b8536a12a9c", size = 9615444, upload-time = "2025-12-10T22:56:18.155Z" }, + { url = "https://files.pythonhosted.org/packages/93/a5/de89ac80f10b8dc615807ee1133cd99ac74082581196d4d9590bea10690d/matplotlib-3.10.8-cp314-cp314-win_amd64.whl", hash = "sha256:83d282364ea9f3e52363da262ce32a09dfe241e4080dcedda3c0db059d3c1f11", size = 8272719, upload-time = "2025-12-10T22:56:20.366Z" }, + { url = "https://files.pythonhosted.org/packages/69/ce/b006495c19ccc0a137b48083168a37bd056392dee02f87dba0472f2797fe/matplotlib-3.10.8-cp314-cp314-win_arm64.whl", hash = "sha256:2c1998e92cd5999e295a731bcb2911c75f597d937341f3030cc24ef2733d78a8", size = 8144205, upload-time = "2025-12-10T22:56:22.239Z" }, + { url = "https://files.pythonhosted.org/packages/68/d9/b31116a3a855bd313c6fcdb7226926d59b041f26061c6c5b1be66a08c826/matplotlib-3.10.8-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b5a2b97dbdc7d4f353ebf343744f1d1f1cca8aa8bfddb4262fcf4306c3761d50", size = 8305785, upload-time = "2025-12-10T22:56:24.218Z" }, + { url = "https://files.pythonhosted.org/packages/1e/90/6effe8103f0272685767ba5f094f453784057072f49b393e3ea178fe70a5/matplotlib-3.10.8-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3f5c3e4da343bba819f0234186b9004faba952cc420fbc522dc4e103c1985908", size = 8198361, upload-time = "2025-12-10T22:56:26.787Z" }, + { url = "https://files.pythonhosted.org/packages/d7/65/a73188711bea603615fc0baecca1061429ac16940e2385433cc778a9d8e7/matplotlib-3.10.8-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f62550b9a30afde8c1c3ae450e5eb547d579dd69b25c2fc7a1c67f934c1717a", size = 9561357, upload-time = "2025-12-10T22:56:28.953Z" }, + { url = "https://files.pythonhosted.org/packages/f4/3d/b5c5d5d5be8ce63292567f0e2c43dde9953d3ed86ac2de0a72e93c8f07a1/matplotlib-3.10.8-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:495672de149445ec1b772ff2c9ede9b769e3cb4f0d0aa7fa730d7f59e2d4e1c1", size = 9823610, upload-time = "2025-12-10T22:56:31.455Z" }, + { url = "https://files.pythonhosted.org/packages/4d/4b/e7beb6bbd49f6bae727a12b270a2654d13c397576d25bd6786e47033300f/matplotlib-3.10.8-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:595ba4d8fe983b88f0eec8c26a241e16d6376fe1979086232f481f8f3f67494c", size = 9614011, upload-time = "2025-12-10T22:56:33.85Z" }, + { url = "https://files.pythonhosted.org/packages/7c/e6/76f2813d31f032e65f6f797e3f2f6e4aab95b65015924b1c51370395c28a/matplotlib-3.10.8-cp314-cp314t-win_amd64.whl", hash = "sha256:25d380fe8b1dc32cf8f0b1b448470a77afb195438bafdf1d858bfb876f3edf7b", size = 8362801, upload-time = "2025-12-10T22:56:36.107Z" }, + { url = "https://files.pythonhosted.org/packages/5d/49/d651878698a0b67f23aa28e17f45a6d6dd3d3f933fa29087fa4ce5947b5a/matplotlib-3.10.8-cp314-cp314t-win_arm64.whl", hash = "sha256:113bb52413ea508ce954a02c10ffd0d565f9c3bc7f2eddc27dfe1731e71c7b5f", size = 8192560, upload-time = "2025-12-10T22:56:38.008Z" }, + { url = "https://files.pythonhosted.org/packages/f5/43/31d59500bb950b0d188e149a2e552040528c13d6e3d6e84d0cccac593dcd/matplotlib-3.10.8-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:f97aeb209c3d2511443f8797e3e5a569aebb040d4f8bc79aa3ee78a8fb9e3dd8", size = 8237252, upload-time = "2025-12-10T22:56:39.529Z" }, + { url = "https://files.pythonhosted.org/packages/0c/2c/615c09984f3c5f907f51c886538ad785cf72e0e11a3225de2c0f9442aecc/matplotlib-3.10.8-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:fb061f596dad3a0f52b60dc6a5dec4a0c300dec41e058a7efe09256188d170b7", size = 8124693, upload-time = "2025-12-10T22:56:41.758Z" }, + { url = "https://files.pythonhosted.org/packages/91/e1/2757277a1c56041e1fc104b51a0f7b9a4afc8eb737865d63cababe30bc61/matplotlib-3.10.8-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:12d90df9183093fcd479f4172ac26b322b1248b15729cb57f42f71f24c7e37a3", size = 8702205, upload-time = "2025-12-10T22:56:43.415Z" }, + { url = "https://files.pythonhosted.org/packages/04/30/3afaa31c757f34b7725ab9d2ba8b48b5e89c2019c003e7d0ead143aabc5a/matplotlib-3.10.8-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:6da7c2ce169267d0d066adcf63758f0604aa6c3eebf67458930f9d9b79ad1db1", size = 8249198, upload-time = "2025-12-10T22:56:45.584Z" }, + { url = "https://files.pythonhosted.org/packages/48/2f/6334aec331f57485a642a7c8be03cb286f29111ae71c46c38b363230063c/matplotlib-3.10.8-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:9153c3292705be9f9c64498a8872118540c3f4123d1a1c840172edf262c8be4a", size = 8136817, upload-time = "2025-12-10T22:56:47.339Z" }, + { url = "https://files.pythonhosted.org/packages/73/e4/6d6f14b2a759c622f191b2d67e9075a3f56aaccb3be4bb9bb6890030d0a0/matplotlib-3.10.8-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1ae029229a57cd1e8fe542485f27e7ca7b23aa9e8944ddb4985d0bc444f1eca2", size = 8713867, upload-time = "2025-12-10T22:56:48.954Z" }, +] + [[package]] name = "matplotlib-inline" version = "0.2.1" @@ -1293,6 +1715,9 @@ version = "0.1.0" source = { editable = "." } dependencies = [ { name = "jupyterlab" }, + { name = "matplotlib" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.4.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "opencv-contrib-python" }, { name = "rich" }, ] @@ -1308,6 +1733,8 @@ dev = [ [package.metadata] requires-dist = [ { name = "jupyterlab", specifier = ">=4.5.6" }, + { name = "matplotlib", specifier = ">=3.10.7" }, + { name = "numpy", specifier = ">=2.2.6" }, { name = "opencv-contrib-python", specifier = ">=4.13.0.92" }, { name = "pre-commit", marker = "extra == 'dev'", specifier = "==3.3.2" }, { name = "pyright", marker = "extra == 'dev'", specifier = ">=1.1.308" }, @@ -1384,6 +1811,104 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523", size = 63772, upload-time = "2023-11-25T06:56:14.81Z" }, ] +[[package]] +name = "pillow" +version = "12.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1f/42/5c74462b4fd957fcd7b13b04fb3205ff8349236ea74c7c375766d6c82288/pillow-12.1.1.tar.gz", hash = "sha256:9ad8fa5937ab05218e2b6a4cff30295ad35afd2f83ac592e68c0d871bb0fdbc4", size = 46980264, upload-time = "2026-02-11T04:23:07.146Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/30/5bd3d794762481f8c8ae9c80e7b76ecea73b916959eb587521358ef0b2f9/pillow-12.1.1-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:1f1625b72740fdda5d77b4def688eb8fd6490975d06b909fd19f13f391e077e0", size = 5304099, upload-time = "2026-02-11T04:20:06.13Z" }, + { url = "https://files.pythonhosted.org/packages/bd/c1/aab9e8f3eeb4490180e357955e15c2ef74b31f64790ff356c06fb6cf6d84/pillow-12.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:178aa072084bd88ec759052feca8e56cbb14a60b39322b99a049e58090479713", size = 4657880, upload-time = "2026-02-11T04:20:09.291Z" }, + { url = "https://files.pythonhosted.org/packages/f1/0a/9879e30d56815ad529d3985aeff5af4964202425c27261a6ada10f7cbf53/pillow-12.1.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b66e95d05ba806247aaa1561f080abc7975daf715c30780ff92a20e4ec546e1b", size = 6222587, upload-time = "2026-02-11T04:20:10.82Z" }, + { url = "https://files.pythonhosted.org/packages/5a/5f/a1b72ff7139e4f89014e8d451442c74a774d5c43cd938fb0a9f878576b37/pillow-12.1.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:89c7e895002bbe49cdc5426150377cbbc04767d7547ed145473f496dfa40408b", size = 8027678, upload-time = "2026-02-11T04:20:12.455Z" }, + { url = "https://files.pythonhosted.org/packages/e2/c2/c7cb187dac79a3d22c3ebeae727abee01e077c8c7d930791dc592f335153/pillow-12.1.1-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a5cbdcddad0af3da87cb16b60d23648bc3b51967eb07223e9fed77a82b457c4", size = 6335777, upload-time = "2026-02-11T04:20:14.441Z" }, + { url = "https://files.pythonhosted.org/packages/0c/7b/f9b09a7804ec7336effb96c26d37c29d27225783dc1501b7d62dcef6ae25/pillow-12.1.1-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9f51079765661884a486727f0729d29054242f74b46186026582b4e4769918e4", size = 7027140, upload-time = "2026-02-11T04:20:16.387Z" }, + { url = "https://files.pythonhosted.org/packages/98/b2/2fa3c391550bd421b10849d1a2144c44abcd966daadd2f7c12e19ea988c4/pillow-12.1.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:99c1506ea77c11531d75e3a412832a13a71c7ebc8192ab9e4b2e355555920e3e", size = 6449855, upload-time = "2026-02-11T04:20:18.554Z" }, + { url = "https://files.pythonhosted.org/packages/96/ff/9caf4b5b950c669263c39e96c78c0d74a342c71c4f43fd031bb5cb7ceac9/pillow-12.1.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:36341d06738a9f66c8287cf8b876d24b18db9bd8740fa0672c74e259ad408cff", size = 7151329, upload-time = "2026-02-11T04:20:20.646Z" }, + { url = "https://files.pythonhosted.org/packages/7b/f8/4b24841f582704da675ca535935bccb32b00a6da1226820845fac4a71136/pillow-12.1.1-cp310-cp310-win32.whl", hash = "sha256:6c52f062424c523d6c4db85518774cc3d50f5539dd6eed32b8f6229b26f24d40", size = 6325574, upload-time = "2026-02-11T04:20:22.43Z" }, + { url = "https://files.pythonhosted.org/packages/f8/f9/9f6b01c0881d7036063aa6612ef04c0e2cad96be21325a1e92d0203f8e91/pillow-12.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:c6008de247150668a705a6338156efb92334113421ceecf7438a12c9a12dab23", size = 7032347, upload-time = "2026-02-11T04:20:23.932Z" }, + { url = "https://files.pythonhosted.org/packages/79/13/c7922edded3dcdaf10c59297540b72785620abc0538872c819915746757d/pillow-12.1.1-cp310-cp310-win_arm64.whl", hash = "sha256:1a9b0ee305220b392e1124a764ee4265bd063e54a751a6b62eff69992f457fa9", size = 2453457, upload-time = "2026-02-11T04:20:25.392Z" }, + { url = "https://files.pythonhosted.org/packages/2b/46/5da1ec4a5171ee7bf1a0efa064aba70ba3d6e0788ce3f5acd1375d23c8c0/pillow-12.1.1-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:e879bb6cd5c73848ef3b2b48b8af9ff08c5b71ecda8048b7dd22d8a33f60be32", size = 5304084, upload-time = "2026-02-11T04:20:27.501Z" }, + { url = "https://files.pythonhosted.org/packages/78/93/a29e9bc02d1cf557a834da780ceccd54e02421627200696fcf805ebdc3fb/pillow-12.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:365b10bb9417dd4498c0e3b128018c4a624dc11c7b97d8cc54effe3b096f4c38", size = 4657866, upload-time = "2026-02-11T04:20:29.827Z" }, + { url = "https://files.pythonhosted.org/packages/13/84/583a4558d492a179d31e4aae32eadce94b9acf49c0337c4ce0b70e0a01f2/pillow-12.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d4ce8e329c93845720cd2014659ca67eac35f6433fd3050393d85f3ecef0dad5", size = 6232148, upload-time = "2026-02-11T04:20:31.329Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e2/53c43334bbbb2d3b938978532fbda8e62bb6e0b23a26ce8592f36bcc4987/pillow-12.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc354a04072b765eccf2204f588a7a532c9511e8b9c7f900e1b64e3e33487090", size = 8038007, upload-time = "2026-02-11T04:20:34.225Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a6/3d0e79c8a9d58150dd98e199d7c1c56861027f3829a3a60b3c2784190180/pillow-12.1.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7e7976bf1910a8116b523b9f9f58bf410f3e8aa330cd9a2bb2953f9266ab49af", size = 6345418, upload-time = "2026-02-11T04:20:35.858Z" }, + { url = "https://files.pythonhosted.org/packages/a2/c8/46dfeac5825e600579157eea177be43e2f7ff4a99da9d0d0a49533509ac5/pillow-12.1.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:597bd9c8419bc7c6af5604e55847789b69123bbe25d65cc6ad3012b4f3c98d8b", size = 7034590, upload-time = "2026-02-11T04:20:37.91Z" }, + { url = "https://files.pythonhosted.org/packages/af/bf/e6f65d3db8a8bbfeaf9e13cc0417813f6319863a73de934f14b2229ada18/pillow-12.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2c1fc0f2ca5f96a3c8407e41cca26a16e46b21060fe6d5b099d2cb01412222f5", size = 6458655, upload-time = "2026-02-11T04:20:39.496Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c2/66091f3f34a25894ca129362e510b956ef26f8fb67a0e6417bc5744e56f1/pillow-12.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:578510d88c6229d735855e1f278aa305270438d36a05031dfaae5067cc8eb04d", size = 7159286, upload-time = "2026-02-11T04:20:41.139Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5a/24bc8eb526a22f957d0cec6243146744966d40857e3d8deb68f7902ca6c1/pillow-12.1.1-cp311-cp311-win32.whl", hash = "sha256:7311c0a0dcadb89b36b7025dfd8326ecfa36964e29913074d47382706e516a7c", size = 6328663, upload-time = "2026-02-11T04:20:43.184Z" }, + { url = "https://files.pythonhosted.org/packages/31/03/bef822e4f2d8f9d7448c133d0a18185d3cce3e70472774fffefe8b0ed562/pillow-12.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:fbfa2a7c10cc2623f412753cddf391c7f971c52ca40a3f65dc5039b2939e8563", size = 7031448, upload-time = "2026-02-11T04:20:44.696Z" }, + { url = "https://files.pythonhosted.org/packages/49/70/f76296f53610bd17b2e7d31728b8b7825e3ac3b5b3688b51f52eab7c0818/pillow-12.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:b81b5e3511211631b3f672a595e3221252c90af017e399056d0faabb9538aa80", size = 2453651, upload-time = "2026-02-11T04:20:46.243Z" }, + { url = "https://files.pythonhosted.org/packages/07/d3/8df65da0d4df36b094351dce696f2989bec731d4f10e743b1c5f4da4d3bf/pillow-12.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ab323b787d6e18b3d91a72fc99b1a2c28651e4358749842b8f8dfacd28ef2052", size = 5262803, upload-time = "2026-02-11T04:20:47.653Z" }, + { url = "https://files.pythonhosted.org/packages/d6/71/5026395b290ff404b836e636f51d7297e6c83beceaa87c592718747e670f/pillow-12.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:adebb5bee0f0af4909c30db0d890c773d1a92ffe83da908e2e9e720f8edf3984", size = 4657601, upload-time = "2026-02-11T04:20:49.328Z" }, + { url = "https://files.pythonhosted.org/packages/b1/2e/1001613d941c67442f745aff0f7cc66dd8df9a9c084eb497e6a543ee6f7e/pillow-12.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bb66b7cc26f50977108790e2456b7921e773f23db5630261102233eb355a3b79", size = 6234995, upload-time = "2026-02-11T04:20:51.032Z" }, + { url = "https://files.pythonhosted.org/packages/07/26/246ab11455b2549b9233dbd44d358d033a2f780fa9007b61a913c5b2d24e/pillow-12.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aee2810642b2898bb187ced9b349e95d2a7272930796e022efaf12e99dccd293", size = 8045012, upload-time = "2026-02-11T04:20:52.882Z" }, + { url = "https://files.pythonhosted.org/packages/b2/8b/07587069c27be7535ac1fe33874e32de118fbd34e2a73b7f83436a88368c/pillow-12.1.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a0b1cd6232e2b618adcc54d9882e4e662a089d5768cd188f7c245b4c8c44a397", size = 6349638, upload-time = "2026-02-11T04:20:54.444Z" }, + { url = "https://files.pythonhosted.org/packages/ff/79/6df7b2ee763d619cda2fb4fea498e5f79d984dae304d45a8999b80d6cf5c/pillow-12.1.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7aac39bcf8d4770d089588a2e1dd111cbaa42df5a94be3114222057d68336bd0", size = 7041540, upload-time = "2026-02-11T04:20:55.97Z" }, + { url = "https://files.pythonhosted.org/packages/2c/5e/2ba19e7e7236d7529f4d873bdaf317a318896bac289abebd4bb00ef247f0/pillow-12.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ab174cd7d29a62dd139c44bf74b698039328f45cb03b4596c43473a46656b2f3", size = 6462613, upload-time = "2026-02-11T04:20:57.542Z" }, + { url = "https://files.pythonhosted.org/packages/03/03/31216ec124bb5c3dacd74ce8efff4cc7f52643653bad4825f8f08c697743/pillow-12.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:339ffdcb7cbeaa08221cd401d517d4b1fe7a9ed5d400e4a8039719238620ca35", size = 7166745, upload-time = "2026-02-11T04:20:59.196Z" }, + { url = "https://files.pythonhosted.org/packages/1f/e7/7c4552d80052337eb28653b617eafdef39adfb137c49dd7e831b8dc13bc5/pillow-12.1.1-cp312-cp312-win32.whl", hash = "sha256:5d1f9575a12bed9e9eedd9a4972834b08c97a352bd17955ccdebfeca5913fa0a", size = 6328823, upload-time = "2026-02-11T04:21:01.385Z" }, + { url = "https://files.pythonhosted.org/packages/3d/17/688626d192d7261bbbf98846fc98995726bddc2c945344b65bec3a29d731/pillow-12.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:21329ec8c96c6e979cd0dfd29406c40c1d52521a90544463057d2aaa937d66a6", size = 7033367, upload-time = "2026-02-11T04:21:03.536Z" }, + { url = "https://files.pythonhosted.org/packages/ed/fe/a0ef1f73f939b0eca03ee2c108d0043a87468664770612602c63266a43c4/pillow-12.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:af9a332e572978f0218686636610555ae3defd1633597be015ed50289a03c523", size = 2453811, upload-time = "2026-02-11T04:21:05.116Z" }, + { url = "https://files.pythonhosted.org/packages/d5/11/6db24d4bd7685583caeae54b7009584e38da3c3d4488ed4cd25b439de486/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d242e8ac078781f1de88bf823d70c1a9b3c7950a44cdf4b7c012e22ccbcd8e4e", size = 4062689, upload-time = "2026-02-11T04:21:06.804Z" }, + { url = "https://files.pythonhosted.org/packages/33/c0/ce6d3b1fe190f0021203e0d9b5b99e57843e345f15f9ef22fcd43842fd21/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:02f84dfad02693676692746df05b89cf25597560db2857363a208e393429f5e9", size = 4138535, upload-time = "2026-02-11T04:21:08.452Z" }, + { url = "https://files.pythonhosted.org/packages/a0/c6/d5eb6a4fb32a3f9c21a8c7613ec706534ea1cf9f4b3663e99f0d83f6fca8/pillow-12.1.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:e65498daf4b583091ccbb2556c7000abf0f3349fcd57ef7adc9a84a394ed29f6", size = 3601364, upload-time = "2026-02-11T04:21:10.194Z" }, + { url = "https://files.pythonhosted.org/packages/14/a1/16c4b823838ba4c9c52c0e6bbda903a3fe5a1bdbf1b8eb4fff7156f3e318/pillow-12.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c6db3b84c87d48d0088943bf33440e0c42370b99b1c2a7989216f7b42eede60", size = 5262561, upload-time = "2026-02-11T04:21:11.742Z" }, + { url = "https://files.pythonhosted.org/packages/bb/ad/ad9dc98ff24f485008aa5cdedaf1a219876f6f6c42a4626c08bc4e80b120/pillow-12.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8b7e5304e34942bf62e15184219a7b5ad4ff7f3bb5cca4d984f37df1a0e1aee2", size = 4657460, upload-time = "2026-02-11T04:21:13.786Z" }, + { url = "https://files.pythonhosted.org/packages/9e/1b/f1a4ea9a895b5732152789326202a82464d5254759fbacae4deea3069334/pillow-12.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:18e5bddd742a44b7e6b1e773ab5db102bd7a94c32555ba656e76d319d19c3850", size = 6232698, upload-time = "2026-02-11T04:21:15.949Z" }, + { url = "https://files.pythonhosted.org/packages/95/f4/86f51b8745070daf21fd2e5b1fe0eb35d4db9ca26e6d58366562fb56a743/pillow-12.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc44ef1f3de4f45b50ccf9136999d71abb99dca7706bc75d222ed350b9fd2289", size = 8041706, upload-time = "2026-02-11T04:21:17.723Z" }, + { url = "https://files.pythonhosted.org/packages/29/9b/d6ecd956bb1266dd1045e995cce9b8d77759e740953a1c9aad9502a0461e/pillow-12.1.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a8eb7ed8d4198bccbd07058416eeec51686b498e784eda166395a23eb99138e", size = 6346621, upload-time = "2026-02-11T04:21:19.547Z" }, + { url = "https://files.pythonhosted.org/packages/71/24/538bff45bde96535d7d998c6fed1a751c75ac7c53c37c90dc2601b243893/pillow-12.1.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47b94983da0c642de92ced1702c5b6c292a84bd3a8e1d1702ff923f183594717", size = 7038069, upload-time = "2026-02-11T04:21:21.378Z" }, + { url = "https://files.pythonhosted.org/packages/94/0e/58cb1a6bc48f746bc4cb3adb8cabff73e2742c92b3bf7a220b7cf69b9177/pillow-12.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:518a48c2aab7ce596d3bf79d0e275661b846e86e4d0e7dec34712c30fe07f02a", size = 6460040, upload-time = "2026-02-11T04:21:23.148Z" }, + { url = "https://files.pythonhosted.org/packages/6c/57/9045cb3ff11eeb6c1adce3b2d60d7d299d7b273a2e6c8381a524abfdc474/pillow-12.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a550ae29b95c6dc13cf69e2c9dc5747f814c54eeb2e32d683e5e93af56caa029", size = 7164523, upload-time = "2026-02-11T04:21:25.01Z" }, + { url = "https://files.pythonhosted.org/packages/73/f2/9be9cb99f2175f0d4dbadd6616ce1bf068ee54a28277ea1bf1fbf729c250/pillow-12.1.1-cp313-cp313-win32.whl", hash = "sha256:a003d7422449f6d1e3a34e3dd4110c22148336918ddbfc6a32581cd54b2e0b2b", size = 6332552, upload-time = "2026-02-11T04:21:27.238Z" }, + { url = "https://files.pythonhosted.org/packages/3f/eb/b0834ad8b583d7d9d42b80becff092082a1c3c156bb582590fcc973f1c7c/pillow-12.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:344cf1e3dab3be4b1fa08e449323d98a2a3f819ad20f4b22e77a0ede31f0faa1", size = 7040108, upload-time = "2026-02-11T04:21:29.462Z" }, + { url = "https://files.pythonhosted.org/packages/d5/7d/fc09634e2aabdd0feabaff4a32f4a7d97789223e7c2042fd805ea4b4d2c2/pillow-12.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:5c0dd1636633e7e6a0afe7bf6a51a14992b7f8e60de5789018ebbdfae55b040a", size = 2453712, upload-time = "2026-02-11T04:21:31.072Z" }, + { url = "https://files.pythonhosted.org/packages/19/2a/b9d62794fc8a0dd14c1943df68347badbd5511103e0d04c035ffe5cf2255/pillow-12.1.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0330d233c1a0ead844fc097a7d16c0abff4c12e856c0b325f231820fee1f39da", size = 5264880, upload-time = "2026-02-11T04:21:32.865Z" }, + { url = "https://files.pythonhosted.org/packages/26/9d/e03d857d1347fa5ed9247e123fcd2a97b6220e15e9cb73ca0a8d91702c6e/pillow-12.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5dae5f21afb91322f2ff791895ddd8889e5e947ff59f71b46041c8ce6db790bc", size = 4660616, upload-time = "2026-02-11T04:21:34.97Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ec/8a6d22afd02570d30954e043f09c32772bfe143ba9285e2fdb11284952cd/pillow-12.1.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2e0c664be47252947d870ac0d327fea7e63985a08794758aa8af5b6cb6ec0c9c", size = 6269008, upload-time = "2026-02-11T04:21:36.623Z" }, + { url = "https://files.pythonhosted.org/packages/3d/1d/6d875422c9f28a4a361f495a5f68d9de4a66941dc2c619103ca335fa6446/pillow-12.1.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:691ab2ac363b8217f7d31b3497108fb1f50faab2f75dfb03284ec2f217e87bf8", size = 8073226, upload-time = "2026-02-11T04:21:38.585Z" }, + { url = "https://files.pythonhosted.org/packages/a1/cd/134b0b6ee5eda6dc09e25e24b40fdafe11a520bc725c1d0bbaa5e00bf95b/pillow-12.1.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9e8064fb1cc019296958595f6db671fba95209e3ceb0c4734c9baf97de04b20", size = 6380136, upload-time = "2026-02-11T04:21:40.562Z" }, + { url = "https://files.pythonhosted.org/packages/7a/a9/7628f013f18f001c1b98d8fffe3452f306a70dc6aba7d931019e0492f45e/pillow-12.1.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:472a8d7ded663e6162dafdf20015c486a7009483ca671cece7a9279b512fcb13", size = 7067129, upload-time = "2026-02-11T04:21:42.521Z" }, + { url = "https://files.pythonhosted.org/packages/1e/f8/66ab30a2193b277785601e82ee2d49f68ea575d9637e5e234faaa98efa4c/pillow-12.1.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:89b54027a766529136a06cfebeecb3a04900397a3590fd252160b888479517bf", size = 6491807, upload-time = "2026-02-11T04:21:44.22Z" }, + { url = "https://files.pythonhosted.org/packages/da/0b/a877a6627dc8318fdb84e357c5e1a758c0941ab1ddffdafd231983788579/pillow-12.1.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:86172b0831b82ce4f7877f280055892b31179e1576aa00d0df3bb1bbf8c3e524", size = 7190954, upload-time = "2026-02-11T04:21:46.114Z" }, + { url = "https://files.pythonhosted.org/packages/83/43/6f732ff85743cf746b1361b91665d9f5155e1483817f693f8d57ea93147f/pillow-12.1.1-cp313-cp313t-win32.whl", hash = "sha256:44ce27545b6efcf0fdbdceb31c9a5bdea9333e664cda58a7e674bb74608b3986", size = 6336441, upload-time = "2026-02-11T04:21:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/3b/44/e865ef3986611bb75bfabdf94a590016ea327833f434558801122979cd0e/pillow-12.1.1-cp313-cp313t-win_amd64.whl", hash = "sha256:a285e3eb7a5a45a2ff504e31f4a8d1b12ef62e84e5411c6804a42197c1cf586c", size = 7045383, upload-time = "2026-02-11T04:21:50.015Z" }, + { url = "https://files.pythonhosted.org/packages/a8/c6/f4fb24268d0c6908b9f04143697ea18b0379490cb74ba9e8d41b898bd005/pillow-12.1.1-cp313-cp313t-win_arm64.whl", hash = "sha256:cc7d296b5ea4d29e6570dabeaed58d31c3fea35a633a69679fb03d7664f43fb3", size = 2456104, upload-time = "2026-02-11T04:21:51.633Z" }, + { url = "https://files.pythonhosted.org/packages/03/d0/bebb3ffbf31c5a8e97241476c4cf8b9828954693ce6744b4a2326af3e16b/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:417423db963cb4be8bac3fc1204fe61610f6abeed1580a7a2cbb2fbda20f12af", size = 4062652, upload-time = "2026-02-11T04:21:53.19Z" }, + { url = "https://files.pythonhosted.org/packages/2d/c0/0e16fb0addda4851445c28f8350d8c512f09de27bbb0d6d0bbf8b6709605/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:b957b71c6b2387610f556a7eb0828afbe40b4a98036fc0d2acfa5a44a0c2036f", size = 4138823, upload-time = "2026-02-11T04:22:03.088Z" }, + { url = "https://files.pythonhosted.org/packages/6b/fb/6170ec655d6f6bb6630a013dd7cf7bc218423d7b5fa9071bf63dc32175ae/pillow-12.1.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:097690ba1f2efdeb165a20469d59d8bb03c55fb6621eb2041a060ae8ea3e9642", size = 3601143, upload-time = "2026-02-11T04:22:04.909Z" }, + { url = "https://files.pythonhosted.org/packages/59/04/dc5c3f297510ba9a6837cbb318b87dd2b8f73eb41a43cc63767f65cb599c/pillow-12.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2815a87ab27848db0321fb78c7f0b2c8649dee134b7f2b80c6a45c6831d75ccd", size = 5266254, upload-time = "2026-02-11T04:22:07.656Z" }, + { url = "https://files.pythonhosted.org/packages/05/30/5db1236b0d6313f03ebf97f5e17cda9ca060f524b2fcc875149a8360b21c/pillow-12.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f7ed2c6543bad5a7d5530eb9e78c53132f93dfa44a28492db88b41cdab885202", size = 4657499, upload-time = "2026-02-11T04:22:09.613Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/008d2ca0eb612e81968e8be0bbae5051efba24d52debf930126d7eaacbba/pillow-12.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:652a2c9ccfb556235b2b501a3a7cf3742148cd22e04b5625c5fe057ea3e3191f", size = 6232137, upload-time = "2026-02-11T04:22:11.434Z" }, + { url = "https://files.pythonhosted.org/packages/70/f1/f14d5b8eeb4b2cd62b9f9f847eb6605f103df89ef619ac68f92f748614ea/pillow-12.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d6e4571eedf43af33d0fc233a382a76e849badbccdf1ac438841308652a08e1f", size = 8042721, upload-time = "2026-02-11T04:22:13.321Z" }, + { url = "https://files.pythonhosted.org/packages/5a/d6/17824509146e4babbdabf04d8171491fa9d776f7061ff6e727522df9bd03/pillow-12.1.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b574c51cf7d5d62e9be37ba446224b59a2da26dc4c1bb2ecbe936a4fb1a7cb7f", size = 6347798, upload-time = "2026-02-11T04:22:15.449Z" }, + { url = "https://files.pythonhosted.org/packages/d1/ee/c85a38a9ab92037a75615aba572c85ea51e605265036e00c5b67dfafbfe2/pillow-12.1.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a37691702ed687799de29a518d63d4682d9016932db66d4e90c345831b02fb4e", size = 7039315, upload-time = "2026-02-11T04:22:17.24Z" }, + { url = "https://files.pythonhosted.org/packages/ec/f3/bc8ccc6e08a148290d7523bde4d9a0d6c981db34631390dc6e6ec34cacf6/pillow-12.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f95c00d5d6700b2b890479664a06e754974848afaae5e21beb4d83c106923fd0", size = 6462360, upload-time = "2026-02-11T04:22:19.111Z" }, + { url = "https://files.pythonhosted.org/packages/f6/ab/69a42656adb1d0665ab051eec58a41f169ad295cf81ad45406963105408f/pillow-12.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:559b38da23606e68681337ad74622c4dbba02254fc9cb4488a305dd5975c7eeb", size = 7165438, upload-time = "2026-02-11T04:22:21.041Z" }, + { url = "https://files.pythonhosted.org/packages/02/46/81f7aa8941873f0f01d4b55cc543b0a3d03ec2ee30d617a0448bf6bd6dec/pillow-12.1.1-cp314-cp314-win32.whl", hash = "sha256:03edcc34d688572014ff223c125a3f77fb08091e4607e7745002fc214070b35f", size = 6431503, upload-time = "2026-02-11T04:22:22.833Z" }, + { url = "https://files.pythonhosted.org/packages/40/72/4c245f7d1044b67affc7f134a09ea619d4895333d35322b775b928180044/pillow-12.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:50480dcd74fa63b8e78235957d302d98d98d82ccbfac4c7e12108ba9ecbdba15", size = 7176748, upload-time = "2026-02-11T04:22:24.64Z" }, + { url = "https://files.pythonhosted.org/packages/e4/ad/8a87bdbe038c5c698736e3348af5c2194ffb872ea52f11894c95f9305435/pillow-12.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:5cb1785d97b0c3d1d1a16bc1d710c4a0049daefc4935f3a8f31f827f4d3d2e7f", size = 2544314, upload-time = "2026-02-11T04:22:26.685Z" }, + { url = "https://files.pythonhosted.org/packages/6c/9d/efd18493f9de13b87ede7c47e69184b9e859e4427225ea962e32e56a49bc/pillow-12.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1f90cff8aa76835cba5769f0b3121a22bd4eb9e6884cfe338216e557a9a548b8", size = 5268612, upload-time = "2026-02-11T04:22:29.884Z" }, + { url = "https://files.pythonhosted.org/packages/f8/f1/4f42eb2b388eb2ffc660dcb7f7b556c1015c53ebd5f7f754965ef997585b/pillow-12.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1f1be78ce9466a7ee64bfda57bdba0f7cc499d9794d518b854816c41bf0aa4e9", size = 4660567, upload-time = "2026-02-11T04:22:31.799Z" }, + { url = "https://files.pythonhosted.org/packages/01/54/df6ef130fa43e4b82e32624a7b821a2be1c5653a5fdad8469687a7db4e00/pillow-12.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:42fc1f4677106188ad9a55562bbade416f8b55456f522430fadab3cef7cd4e60", size = 6269951, upload-time = "2026-02-11T04:22:33.921Z" }, + { url = "https://files.pythonhosted.org/packages/a9/48/618752d06cc44bb4aae8ce0cd4e6426871929ed7b46215638088270d9b34/pillow-12.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:98edb152429ab62a1818039744d8fbb3ccab98a7c29fc3d5fcef158f3f1f68b7", size = 8074769, upload-time = "2026-02-11T04:22:35.877Z" }, + { url = "https://files.pythonhosted.org/packages/c3/bd/f1d71eb39a72fa088d938655afba3e00b38018d052752f435838961127d8/pillow-12.1.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d470ab1178551dd17fdba0fef463359c41aaa613cdcd7ff8373f54be629f9f8f", size = 6381358, upload-time = "2026-02-11T04:22:37.698Z" }, + { url = "https://files.pythonhosted.org/packages/64/ef/c784e20b96674ed36a5af839305f55616f8b4f8aa8eeccf8531a6e312243/pillow-12.1.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6408a7b064595afcab0a49393a413732a35788f2a5092fdc6266952ed67de586", size = 7068558, upload-time = "2026-02-11T04:22:39.597Z" }, + { url = "https://files.pythonhosted.org/packages/73/cb/8059688b74422ae61278202c4e1ad992e8a2e7375227be0a21c6b87ca8d5/pillow-12.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5d8c41325b382c07799a3682c1c258469ea2ff97103c53717b7893862d0c98ce", size = 6493028, upload-time = "2026-02-11T04:22:42.73Z" }, + { url = "https://files.pythonhosted.org/packages/c6/da/e3c008ed7d2dd1f905b15949325934510b9d1931e5df999bb15972756818/pillow-12.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7697918b5be27424e9ce568193efd13d925c4481dd364e43f5dff72d33e10f8", size = 7191940, upload-time = "2026-02-11T04:22:44.543Z" }, + { url = "https://files.pythonhosted.org/packages/01/4a/9202e8d11714c1fc5951f2e1ef362f2d7fbc595e1f6717971d5dd750e969/pillow-12.1.1-cp314-cp314t-win32.whl", hash = "sha256:d2912fd8114fc5545aa3a4b5576512f64c55a03f3ebcca4c10194d593d43ea36", size = 6438736, upload-time = "2026-02-11T04:22:46.347Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ca/cbce2327eb9885476b3957b2e82eb12c866a8b16ad77392864ad601022ce/pillow-12.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:4ceb838d4bd9dab43e06c363cab2eebf63846d6a4aeaea283bbdfd8f1a8ed58b", size = 7182894, upload-time = "2026-02-11T04:22:48.114Z" }, + { url = "https://files.pythonhosted.org/packages/ec/d2/de599c95ba0a973b94410477f8bf0b6f0b5e67360eb89bcb1ad365258beb/pillow-12.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:7b03048319bfc6170e93bd60728a1af51d3dd7704935feb228c4d4faab35d334", size = 2546446, upload-time = "2026-02-11T04:22:50.342Z" }, + { url = "https://files.pythonhosted.org/packages/56/11/5d43209aa4cb58e0cc80127956ff1796a68b928e6324bbf06ef4db34367b/pillow-12.1.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:600fd103672b925fe62ed08e0d874ea34d692474df6f4bf7ebe148b30f89f39f", size = 5228606, upload-time = "2026-02-11T04:22:52.106Z" }, + { url = "https://files.pythonhosted.org/packages/5f/d5/3b005b4e4fda6698b371fa6c21b097d4707585d7db99e98d9b0b87ac612a/pillow-12.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:665e1b916b043cef294bc54d47bf02d87e13f769bc4bc5fa225a24b3a6c5aca9", size = 4622321, upload-time = "2026-02-11T04:22:53.827Z" }, + { url = "https://files.pythonhosted.org/packages/df/36/ed3ea2d594356fd8037e5a01f6156c74bc8d92dbb0fa60746cc96cabb6e8/pillow-12.1.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:495c302af3aad1ca67420ddd5c7bd480c8867ad173528767d906428057a11f0e", size = 5247579, upload-time = "2026-02-11T04:22:56.094Z" }, + { url = "https://files.pythonhosted.org/packages/54/9a/9cc3e029683cf6d20ae5085da0dafc63148e3252c2f13328e553aaa13cfb/pillow-12.1.1-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8fd420ef0c52c88b5a035a0886f367748c72147b2b8f384c9d12656678dfdfa9", size = 6989094, upload-time = "2026-02-11T04:22:58.288Z" }, + { url = "https://files.pythonhosted.org/packages/00/98/fc53ab36da80b88df0967896b6c4b4cd948a0dc5aa40a754266aa3ae48b3/pillow-12.1.1-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f975aa7ef9684ce7e2c18a3aa8f8e2106ce1e46b94ab713d156b2898811651d3", size = 5313850, upload-time = "2026-02-11T04:23:00.554Z" }, + { url = "https://files.pythonhosted.org/packages/30/02/00fa585abfd9fe9d73e5f6e554dc36cc2b842898cbfc46d70353dae227f8/pillow-12.1.1-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8089c852a56c2966cf18835db62d9b34fef7ba74c726ad943928d494fa7f4735", size = 5963343, upload-time = "2026-02-11T04:23:02.934Z" }, + { url = "https://files.pythonhosted.org/packages/f2/26/c56ce33ca856e358d27fda9676c055395abddb82c35ac0f593877ed4562e/pillow-12.1.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:cb9bb857b2d057c6dfc72ac5f3b44836924ba15721882ef103cecb40d002d80e", size = 7029880, upload-time = "2026-02-11T04:23:04.783Z" }, +] + [[package]] name = "platformdirs" version = "4.9.4" @@ -1503,6 +2028,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, ] +[[package]] +name = "pyparsing" +version = "3.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574, upload-time = "2026-01-21T03:57:59.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" }, +] + [[package]] name = "pyright" version = "1.1.408" From 72ecde8a61e0c215a201c69fc3eb65546ce5685e Mon Sep 17 00:00:00 2001 From: author31 Date: Fri, 20 Mar 2026 14:51:13 +0800 Subject: [PATCH 4/8] use headless opencv --- .gitignore | 1 + AGENTS.md | 1 + pyproject.toml | 2 +- uv.lock | 22 +++++++++++----------- 4 files changed, 14 insertions(+), 12 deletions(-) diff --git a/.gitignore b/.gitignore index e0d904c..b4c8015 100644 --- a/.gitignore +++ b/.gitignore @@ -226,3 +226,4 @@ demos/* workspace/* +*.png diff --git a/AGENTS.md b/AGENTS.md index 4eda873..8f73f43 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -145,6 +145,7 @@ Outcome: - Make errors notebook-friendly and actionable. - Fail fast on required dependencies. If a feature depends on a package that is required by this repo, import it normally and let missing dependencies fail at import time rather than adding deferred runtime guards. Reserve lazy imports or fallback guards for truly optional integrations only. - In `src/opai/presentation/facade.py`, prefer comprehensive public-function implementations over underscore-prefixed helper members. Keep the facade logic visible in the public functions rather than hiding it behind private abstractions unless the file would otherwise become unworkable. +- Prefer comprehensive workflow implementations over tiny underscore-prefixed helpers across the repo, especially in application modules. Do not extract routine 2-5 line steps into private functions unless they carry real domain meaning, are reused meaningfully, or materially reduce complexity. - Do not import types from the `typing` module. Python 3.10+ native annotations are the repo standard. - Prefer built-in generics such as `list[str]`, `dict[str, object]`, `tuple[int, ...]`, and unions like `Path | None`. - When a protocol-style annotation is needed, import it from `collections.abc` instead of `typing`. diff --git a/pyproject.toml b/pyproject.toml index 6800cee..d9b92fc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,7 +11,7 @@ dependencies = [ "jupyterlab>=4.5.6", "matplotlib>=3.10.7", "numpy>=2.2.6", - "opencv-contrib-python>=4.13.0.92", + "opencv-contrib-python-headless>=4.13.0.92", "rich>=14.1.0", ] diff --git a/uv.lock b/uv.lock index 79c356b..3e1ee26 100644 --- a/uv.lock +++ b/uv.lock @@ -1718,7 +1718,7 @@ dependencies = [ { name = "matplotlib" }, { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "numpy", version = "2.4.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "opencv-contrib-python" }, + { name = "opencv-contrib-python-headless" }, { name = "rich" }, ] @@ -1735,7 +1735,7 @@ requires-dist = [ { name = "jupyterlab", specifier = ">=4.5.6" }, { name = "matplotlib", specifier = ">=3.10.7" }, { name = "numpy", specifier = ">=2.2.6" }, - { name = "opencv-contrib-python", specifier = ">=4.13.0.92" }, + { name = "opencv-contrib-python-headless", specifier = ">=4.13.0.92" }, { name = "pre-commit", marker = "extra == 'dev'", specifier = "==3.3.2" }, { name = "pyright", marker = "extra == 'dev'", specifier = ">=1.1.308" }, { name = "pytest", marker = "extra == 'dev'" }, @@ -1745,7 +1745,7 @@ requires-dist = [ provides-extras = ["dev"] [[package]] -name = "opencv-contrib-python" +name = "opencv-contrib-python-headless" version = "4.13.0.92" source = { registry = "https://pypi.org/simple" } dependencies = [ @@ -1753,14 +1753,14 @@ dependencies = [ { name = "numpy", version = "2.4.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/4c/a45c96b9fe90b2c48ee604f5176eb7deb46ce7c2e87c8d819d2945dbcab6/opencv_contrib_python-4.13.0.92-cp37-abi3-macosx_13_0_arm64.whl", hash = "sha256:53c8ab81376210dda5836307eb6bda7266f39a3820a9a070c7131510ba815fe1", size = 52041546, upload-time = "2026-02-05T07:01:29.918Z" }, - { url = "https://files.pythonhosted.org/packages/8b/6c/ba1f3177927deeb3002b62fb8db89daea3b5dc732d61de5bf4c73ed6ebf7/opencv_contrib_python-4.13.0.92-cp37-abi3-macosx_14_0_x86_64.whl", hash = "sha256:1973d0fc773873f9d1b5bf0d1b65895da2f47b06ba033b7d58393f5c28ba0778", size = 38830319, upload-time = "2026-02-05T07:01:47.222Z" }, - { url = "https://files.pythonhosted.org/packages/ff/7a/fe87eaf109b454af4a2579f46958b3cafb0f804b9c788c108760723a9bb7/opencv_contrib_python-4.13.0.92-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5f9cb522dd9e465dfca3536c15288f7936b9827432fb9c885eaf94dc5f88c2a3", size = 53339457, upload-time = "2026-02-05T10:09:02.332Z" }, - { url = "https://files.pythonhosted.org/packages/b6/27/3665ca4b75ddfd218f9ab139f0463d9571e87aaf59391d3c4f5546c08df7/opencv_contrib_python-4.13.0.92-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f9ceec5886419860a31b518991a99e978e5a6a78dca1470103ad4ede0155f156", size = 76591184, upload-time = "2026-02-05T10:11:51.298Z" }, - { url = "https://files.pythonhosted.org/packages/f3/11/10c46e9527c4591d5264117debd8fe0e21bb23dbf378ce760add6b1e85b6/opencv_contrib_python-4.13.0.92-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:a3c54377c5cf9c45d9b1a207df26dc8fe4f1042d07036cb17d80930c04b25d97", size = 52544155, upload-time = "2026-02-05T10:13:32.068Z" }, - { url = "https://files.pythonhosted.org/packages/ba/f6/3c645c21358079097201090de7c30d110f5ec3fa01008e3ee81b0a77a354/opencv_contrib_python-4.13.0.92-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:fc5ee50e2be9d40e913536f7f20cc6f87f25d8e413ebb32a3335ab6edf245d3e", size = 79150872, upload-time = "2026-02-05T10:16:03.465Z" }, - { url = "https://files.pythonhosted.org/packages/90/d7/bf4622e0ed8a93f5a685c76933e287477cf185a160c66478cf144fece489/opencv_contrib_python-4.13.0.92-cp37-abi3-win32.whl", hash = "sha256:f5d02357f4d5575c300eab3ec1c7ecfed3a9a53e55a76927bab7cfc9e0a67b68", size = 36829959, upload-time = "2026-02-05T07:02:22.25Z" }, - { url = "https://files.pythonhosted.org/packages/d9/98/a03f69ff6fb86a67d584ecc990d85a95e6930b96e3f39ad1f8e019cb8ada/opencv_contrib_python-4.13.0.92-cp37-abi3-win_amd64.whl", hash = "sha256:cb694dcf76bb2c8d7fa573fc1a99339e8b6640194d7778381e74cc3445369e45", size = 46486178, upload-time = "2026-02-05T07:02:19.551Z" }, + { url = "https://files.pythonhosted.org/packages/70/b5/9af5b81d9279e9982e21dad52f8a6aec10f7c891ae1e3d3d1b3ce111f8e7/opencv_contrib_python_headless-4.13.0.92-cp37-abi3-macosx_13_0_arm64.whl", hash = "sha256:b0467988c2d56c283b00fb808e0b57f5db2e3ca7743164a3b3efc733bfa03d3a", size = 52041681, upload-time = "2026-02-05T07:01:39.651Z" }, + { url = "https://files.pythonhosted.org/packages/c5/42/f0aef27baf1f376007b018b00f6c304c42c20d31aa8491633c53b18912cb/opencv_contrib_python_headless-4.13.0.92-cp37-abi3-macosx_14_0_x86_64.whl", hash = "sha256:79e503b77880d806a1b106ff8182c6f898347ccfd1db58ffc9a6369acc236c4c", size = 38830456, upload-time = "2026-02-05T07:01:56.47Z" }, + { url = "https://files.pythonhosted.org/packages/14/84/e6b3568f9147b4f114e881fb0e733fd97bdca15452feba78b510351584d1/opencv_contrib_python_headless-4.13.0.92-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:449c1f00a685a3a7dff8d6fa93a70fbfe0de5537c24358ea03a1d996d12b33e8", size = 39355323, upload-time = "2026-02-05T10:17:31.671Z" }, + { url = "https://files.pythonhosted.org/packages/6e/09/89714580c617cf6e9f66eed9137759fc017ab6ab093c2a03227e8ee19578/opencv_contrib_python_headless-4.13.0.92-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c9b028adc04f6579f37227eb1d648bead14fd6fefc58da86df37c8320351f7bd", size = 62147375, upload-time = "2026-02-05T10:20:03.076Z" }, + { url = "https://files.pythonhosted.org/packages/6f/29/abdd2ff2f8f07e9aa37c70edc9987b8aa63730ae70957c378f6f2e9d72d2/opencv_contrib_python_headless-4.13.0.92-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:cdd974b34801f24735d18b1057cfaab1698d5cb02c9bba01dab7dc47201f2ef6", size = 40840722, upload-time = "2026-02-05T10:21:27.877Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0a/3194fdf035ef5123bd8cc3e3ad1a96c1ddeeedd0fdd12aaa0d2cfeb1649a/opencv_contrib_python_headless-4.13.0.92-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:9e26469baed9069f627ea56fa46819690c4545580362071dd09f1dcf47a40f2f", size = 66610130, upload-time = "2026-02-05T10:23:32.427Z" }, + { url = "https://files.pythonhosted.org/packages/ca/4b/afe9b43c02b86b675a3d3ac6fc220473a88016e1acb487f5138efd2d2630/opencv_contrib_python_headless-4.13.0.92-cp37-abi3-win32.whl", hash = "sha256:696a6dd84d309a499efc63644e375f035447b1da777faa2954f2dea7626cc0e7", size = 36708602, upload-time = "2026-02-05T07:02:36.041Z" }, + { url = "https://files.pythonhosted.org/packages/23/22/9fdc70520eb915b46d816f9cc5415458b1bd114a65d7a7e657cbd9b863e5/opencv_contrib_python_headless-4.13.0.92-cp37-abi3-win_amd64.whl", hash = "sha256:dcbb12d04ae74f5dcd782e3b166e1894c6fbdfaaf30866588746205d2a0cde5a", size = 46345416, upload-time = "2026-02-05T07:02:33.446Z" }, ] [[package]] From 42e5d99c622df8500f37a349f61e6aac648841d0 Mon Sep 17 00:00:00 2001 From: author31 Date: Fri, 20 Mar 2026 14:52:03 +0800 Subject: [PATCH 5/8] add generate charuco board --- src/opai/__init__.py | 2 + src/opai/application/__init__.py | 11 +- src/opai/application/calibration.py | 223 +++++++++++++++++---- src/opai/domain/__init__.py | 8 +- src/opai/domain/calibration.py | 76 +++++++ src/opai/domain/grid.py | 40 ---- src/opai/domain/plot.py | 97 +++++++++ src/opai/infrastructure/__init__.py | 4 + src/opai/infrastructure/persistence.py | 45 ++++- src/opai/presentation/__init__.py | 2 + src/opai/presentation/facade.py | 96 ++++++--- tests/test_calibration.py | 93 ++++++++- tests/test_charuco.py | 124 ++++++++++++ tests/test_facade.py | 263 +++++++++++++++++++++---- 14 files changed, 924 insertions(+), 160 deletions(-) delete mode 100644 src/opai/domain/grid.py create mode 100644 src/opai/domain/plot.py create mode 100644 tests/test_charuco.py diff --git a/src/opai/__init__.py b/src/opai/__init__.py index ab64a6e..4045d96 100644 --- a/src/opai/__init__.py +++ b/src/opai/__init__.py @@ -4,6 +4,7 @@ browse_session, calibrate, calibrate_with_video, + generate_charuco_board, get_context, init, list_sessions, @@ -17,6 +18,7 @@ "browse_session", "calibrate", "calibrate_with_video", + "generate_charuco_board", "get_context", "init", "list_sessions", diff --git a/src/opai/application/__init__.py b/src/opai/application/__init__.py index 6bc7c4b..8e103fa 100644 --- a/src/opai/application/__init__.py +++ b/src/opai/application/__init__.py @@ -1,4 +1,4 @@ -from opai.application.calibration import calibrate +from opai.application.calibration import calibrate, generate_charuco_board from opai.application.session import ( add_demos, add_mapping, @@ -6,4 +6,11 @@ list_sessions, ) -__all__ = ["add_demos", "add_mapping", "browse_session", "calibrate", "list_sessions"] +__all__ = [ + "add_demos", + "add_mapping", + "browse_session", + "calibrate", + "generate_charuco_board", + "list_sessions", +] diff --git a/src/opai/application/calibration.py b/src/opai/application/calibration.py index 5dafb72..935ddd7 100644 --- a/src/opai/application/calibration.py +++ b/src/opai/application/calibration.py @@ -6,10 +6,25 @@ import cv2 import numpy as np -from opai.core.exceptions import OPAIValidationError, OPAIWorkflowError -from opai.domain.calibration import CalibrationIntrinsics, CalibrationResult +from opai.core.exceptions import ( + OPAIDependencyError, + OPAIValidationError, + OPAIWorkflowError, +) +from opai.domain.calibration import ( + CalibrationIntrinsics, + CalibrationResult, + CharucoBoardArtifacts, + CharucoBoardConfig, + validate_charuco_board_config, +) from opai.domain.context import Context -from opai.infrastructure.persistence import write_calibration_result +from opai.domain.plot import plot_frames +from opai.infrastructure.persistence import ( + write_calibration_result, + write_charuco_board_config, + write_charuco_board_image, +) from opai.infrastructure.video import ( sample_video_frames as sample_video_frames_from_path, ) @@ -23,6 +38,9 @@ def calibrate( square_length: float, marker_length: float, dictionary: str, + nrows: int | None = None, + ncols: int | None = None, + plot_result: bool = False, ) -> CalibrationResult: _validate_inputs( frames=frames, @@ -32,37 +50,46 @@ def calibrate( marker_length=marker_length, ) - aruco_dictionary = _resolve_dictionary(dictionary) + dictionary_id = getattr(cv2.aruco, dictionary, None) + if dictionary_id is None: + raise OPAIValidationError(f"Unsupported ArUco dictionary: {dictionary}") + aruco_dictionary = cv2.aruco.getPredefinedDictionary(dictionary_id) board = cv2.aruco.CharucoBoard( - (col_count, row_count), - square_length, - marker_length, - aruco_dictionary, + (col_count, row_count), square_length, marker_length, aruco_dictionary ) + charuco_detector = cv2.aruco.CharucoDetector(board) - image_height, image_width = _get_frame_size(frames) + image_height, image_width = (int(value) for value in frames[0].shape[:2]) image_size = (image_width, image_height) all_charuco_corners: list[np.ndarray] = [] all_charuco_ids: list[np.ndarray] = [] + detected_corner_frames: list[np.ndarray] = [] for frame in frames: - grayscale = _to_grayscale(frame) - corners, ids, _ = cv2.aruco.detectMarkers(grayscale, aruco_dictionary) - if ids is None or len(ids) == 0: + grayscale = ( + frame if frame.ndim == 2 else cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) + ) + cv2.waitKey(0) + charuco_corners, charuco_ids, _, _ = charuco_detector.detectBoard(grayscale) + + if charuco_ids is None or charuco_corners is None: continue - _, charuco_corners, charuco_ids = cv2.aruco.interpolateCornersCharuco( - markerCorners=corners, - markerIds=ids, - image=grayscale, - board=board, - ) - if charuco_ids is None or charuco_corners is None or len(charuco_ids) < 4: + if int(charuco_ids.size) < 4: continue all_charuco_corners.append(charuco_corners) all_charuco_ids.append(charuco_ids) + plotted_frame = frame.copy() + if plotted_frame.ndim == 2: + plotted_frame = cv2.cvtColor(plotted_frame, cv2.COLOR_GRAY2BGR) + cv2.aruco.drawDetectedCornersCharuco( + image=plotted_frame, + charucoCorners=charuco_corners, + charucoIds=charuco_ids, + ) + detected_corner_frames.append(plotted_frame) if not all_charuco_corners: raise OPAIWorkflowError( @@ -70,13 +97,32 @@ def calibrate( "Verify the board parameters and frame content." ) - ret, camera_matrix, dist_coeffs, rvecs, tvecs = cv2.aruco.calibrateCameraCharuco( - charucoCorners=all_charuco_corners, - charucoIds=all_charuco_ids, + if plot_result: + try: + plot_frames( + detected_corner_frames, + nrows=nrows, + ncols=ncols, + frames_are_bgr=True, + ) + except ModuleNotFoundError as exc: + raise OPAIDependencyError( + "Calibration plotting requires the 'matplotlib' package. Install " + "project dependencies before calling opai.calibrate(...) or " + "opai.calibrate_with_video(...)." + ) from exc + except ValueError as exc: + raise OPAIValidationError(str(exc)) from exc + + object_points, image_points = _build_fisheye_calibration_points( board=board, - imageSize=image_size, - cameraMatrix=None, - distCoeffs=None, + charuco_corners=all_charuco_corners, + charuco_ids=all_charuco_ids, + ) + ret, camera_matrix, dist_coeffs, rvecs, tvecs = _calibrate_fisheye( + object_points=object_points, + image_points=image_points, + image_size=image_size, ) mse_reproj_error = _compute_mse_reprojection_error( @@ -104,6 +150,52 @@ def calibrate( return result +def generate_charuco_board( + ctx: Context, + config: CharucoBoardConfig, +) -> CharucoBoardArtifacts: + validate_charuco_board_config(config) + normalized_config = CharucoBoardConfig( + dictionary=config.dictionary.strip(), + squares_x=config.squares_x, + squares_y=config.squares_y, + square_length=config.square_length, + marker_length=config.marker_length, + image_width_px=config.image_width_px, + image_height_px=config.image_height_px, + margin_size_px=config.margin_size_px, + ) + + dictionary_id = getattr(cv2.aruco, normalized_config.dictionary, None) + if dictionary_id is None: + raise OPAIValidationError( + f"Unsupported ArUco dictionary: {normalized_config.dictionary}" + ) + aruco_dictionary = cv2.aruco.getPredefinedDictionary(dictionary_id) + board = cv2.aruco.CharucoBoard( + (normalized_config.squares_x, normalized_config.squares_y), + normalized_config.square_length, + normalized_config.marker_length, + aruco_dictionary, + ) + board_image = board.generateImage( + (normalized_config.image_width_px, normalized_config.image_height_px), + marginSize=normalized_config.margin_size_px, + ) + + image_path = write_charuco_board_image(ctx.session_directory, board_image) + config_path = write_charuco_board_config( + ctx.session_directory, + normalized_config, + board_image_path=image_path.name, + ) + return CharucoBoardArtifacts( + image_path=image_path, + config_path=config_path, + config=normalized_config, + ) + + def sample_video_frames( video_path: str | Path, frame_sample_step: int, @@ -163,22 +255,67 @@ def _validate_inputs( ) -def _resolve_dictionary(name: str) -> cv2.aruco.Dictionary: - dictionary_id = getattr(cv2.aruco, name, None) - if dictionary_id is None: - raise OPAIValidationError(f"Unsupported ArUco dictionary: {name}") - return cv2.aruco.getPredefinedDictionary(dictionary_id) +def _build_fisheye_calibration_points( + board: cv2.aruco.CharucoBoard, + charuco_corners: Sequence[np.ndarray], + charuco_ids: Sequence[np.ndarray], +) -> tuple[tuple[np.ndarray, ...], tuple[np.ndarray, ...]]: + if len(charuco_corners) != len(charuco_ids): + raise OPAIWorkflowError( + "Calibration failed: inconsistent ChArUco observation lengths." + ) + chessboard_corners = np.asarray(board.getChessboardCorners(), dtype=np.float64) + object_points: list[np.ndarray] = [] + image_points: list[np.ndarray] = [] -def _get_frame_size(frames: Sequence[np.ndarray]) -> tuple[int, int]: - height, width = frames[0].shape[:2] - return int(height), int(width) + for observed_corners, observed_ids in zip(charuco_corners, charuco_ids): + object_points.append( + chessboard_corners[observed_ids.flatten()].reshape(-1, 1, 3) + ) + image_points.append( + np.asarray(observed_corners, dtype=np.float64).reshape(-1, 1, 2) + ) + if not object_points: + raise OPAIWorkflowError( + "Calibration failed: no ChArUco observations available for fisheye calibration." + ) -def _to_grayscale(frame: np.ndarray) -> np.ndarray: - if frame.ndim == 2: - return frame - return cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) + return tuple(object_points), tuple(image_points) + + +def _calibrate_fisheye( + object_points: Sequence[np.ndarray], + image_points: Sequence[np.ndarray], + image_size: tuple[int, int], +) -> tuple[ + float, np.ndarray, np.ndarray, tuple[np.ndarray, ...], tuple[np.ndarray, ...] +]: + ret, camera_matrix, dist_coeffs, rvecs, tvecs = cv2.fisheye.calibrate( + object_points, + image_points, + image_size, + np.eye(3, dtype=np.float64), + np.zeros((4, 1), dtype=np.float64), + flags=( + cv2.fisheye.CALIB_RECOMPUTE_EXTRINSIC + | cv2.fisheye.CALIB_CHECK_COND + | cv2.fisheye.CALIB_FIX_SKEW + ), + criteria=( + cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, + 100, + 1e-6, + ), + ) + return ( + float(ret), + np.asarray(camera_matrix, dtype=np.float64), + np.asarray(dist_coeffs, dtype=np.float64), + tuple(rvecs), + tuple(tvecs), + ) def _compute_mse_reprojection_error( @@ -206,12 +343,12 @@ def _compute_mse_reprojection_error( tvecs, ): object_points = chessboard_corners[observed_ids.flatten()].reshape(-1, 1, 3) - projected_corners, _ = cv2.projectPoints( - objectPoints=object_points, - rvec=rvec, - tvec=tvec, - cameraMatrix=camera_matrix, - distCoeffs=dist_coeffs, + projected_corners, _ = cv2.fisheye.projectPoints( + object_points, + rvec, + tvec, + camera_matrix, + dist_coeffs, ) deltas = projected_corners.reshape(-1, 2) - observed_corners.reshape(-1, 2) squared_error_sum += float(np.sum(deltas * deltas)) diff --git a/src/opai/domain/__init__.py b/src/opai/domain/__init__.py index 3857468..84a8667 100644 --- a/src/opai/domain/__init__.py +++ b/src/opai/domain/__init__.py @@ -1,9 +1,15 @@ -from opai.domain.calibration import CalibrationResult +from opai.domain.calibration import ( + CalibrationResult, + CharucoBoardArtifacts, + CharucoBoardConfig, +) from opai.domain.context import Context from opai.domain.session import DemoAsset, MappingAsset, SessionManifest __all__ = [ "CalibrationResult", + "CharucoBoardArtifacts", + "CharucoBoardConfig", "Context", "DemoAsset", "MappingAsset", diff --git a/src/opai/domain/calibration.py b/src/opai/domain/calibration.py index b0b4ffa..6b2030a 100644 --- a/src/opai/domain/calibration.py +++ b/src/opai/domain/calibration.py @@ -1,9 +1,21 @@ from __future__ import annotations from dataclasses import dataclass +from pathlib import Path import numpy as np +from opai.core.exceptions import OPAIValidationError + +DEFAULT_CHARUCO_DICTIONARY = "DICT_5X5_100" +DEFAULT_CHARUCO_SQUARES_X = 11 +DEFAULT_CHARUCO_SQUARES_Y = 8 +DEFAULT_CHARUCO_SQUARE_LENGTH = 0.03 +DEFAULT_CHARUCO_MARKER_LENGTH = 0.022 +DEFAULT_CHARUCO_IMAGE_WIDTH_PX = 2000 +DEFAULT_CHARUCO_IMAGE_HEIGHT_PX = 1400 +DEFAULT_CHARUCO_MARGIN_SIZE_PX = 20 + @dataclass class CalibrationIntrinsics: @@ -27,3 +39,67 @@ class CalibrationResult: intrinsics: CalibrationIntrinsics camera_matrix: np.ndarray dist_coeffs: np.ndarray + + +@dataclass(frozen=True) +class CharucoBoardConfig: + dictionary: str + squares_x: int + squares_y: int + square_length: float + marker_length: float + image_width_px: int + image_height_px: int + margin_size_px: int + + +@dataclass(frozen=True) +class CharucoBoardArtifacts: + image_path: Path + config_path: Path + config: CharucoBoardConfig + + +def validate_charuco_board_config(config: CharucoBoardConfig) -> None: + if not isinstance(config.dictionary, str) or not config.dictionary.strip(): + raise OPAIValidationError( + "dictionary must be a non-empty ArUco dictionary name.", + details={"dictionary": config.dictionary}, + ) + if config.squares_x <= 1 or config.squares_y <= 1: + raise OPAIValidationError( + "squares_x and squares_y must both be greater than 1.", + details={ + "squares_x": config.squares_x, + "squares_y": config.squares_y, + }, + ) + if config.square_length <= 0 or config.marker_length <= 0: + raise OPAIValidationError( + "square_length and marker_length must both be positive.", + details={ + "square_length": config.square_length, + "marker_length": config.marker_length, + }, + ) + if config.marker_length >= config.square_length: + raise OPAIValidationError( + "marker_length must be smaller than square_length.", + details={ + "square_length": config.square_length, + "marker_length": config.marker_length, + }, + ) + if config.image_width_px <= 0 or config.image_height_px <= 0: + raise OPAIValidationError( + "image_width_px and image_height_px must both be positive.", + details={ + "image_width_px": config.image_width_px, + "image_height_px": config.image_height_px, + }, + ) + if config.margin_size_px < 0: + raise OPAIValidationError( + "margin_size_px must be greater than or equal to 0.", + details={"margin_size_px": config.margin_size_px}, + ) diff --git a/src/opai/domain/grid.py b/src/opai/domain/grid.py deleted file mode 100644 index 1e64077..0000000 --- a/src/opai/domain/grid.py +++ /dev/null @@ -1,40 +0,0 @@ -from __future__ import annotations - -import math -from dataclasses import dataclass - - -@dataclass(frozen=True) -class PlotGrid: - item_count: int - nrows: int - ncols: int - - -def get_plot_grid( - item_count: int, - nrows: int | None = None, - ncols: int | None = None, -) -> PlotGrid: - if item_count <= 0: - raise ValueError("item_count must be positive.") - if nrows is not None and nrows <= 0: - raise ValueError("nrows must be positive when provided.") - if ncols is not None and ncols <= 0: - raise ValueError("ncols must be positive when provided.") - - if nrows is not None: - resolved_rows = nrows - resolved_cols = ncols or int(math.ceil(item_count / nrows)) - elif ncols is not None: - resolved_cols = ncols - resolved_rows = int(math.ceil(item_count / ncols)) - else: - resolved_rows = int(math.sqrt(item_count)) - resolved_cols = int(math.ceil(item_count / resolved_rows)) - - return PlotGrid( - item_count=item_count, - nrows=resolved_rows, - ncols=resolved_cols, - ) diff --git a/src/opai/domain/plot.py b/src/opai/domain/plot.py new file mode 100644 index 0000000..bfb29b6 --- /dev/null +++ b/src/opai/domain/plot.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +import math +from collections.abc import Sequence +from dataclasses import dataclass + +import numpy as np + +BASE_SUBPLOT_WIDTH = 4.0 +BASE_SUBPLOT_HEIGHT = 3.0 +MAX_FIGURE_WIDTH = 16.0 +MAX_FIGURE_HEIGHT = 12.0 +MAX_CANVAS_WIDTH = 1600 +MAX_CANVAS_HEIGHT = 1200 + + +@dataclass(frozen=True) +class PlotGrid: + item_count: int + nrows: int + ncols: int + + +def get_plot_grid( + item_count: int, + nrows: int | None = None, + ncols: int | None = None, +) -> PlotGrid: + if item_count <= 0: + raise ValueError("item_count must be positive.") + if nrows is not None and nrows <= 0: + raise ValueError("nrows must be positive when provided.") + if ncols is not None and ncols <= 0: + raise ValueError("ncols must be positive when provided.") + + if nrows is not None: + resolved_rows = nrows + resolved_cols = ncols or int(math.ceil(item_count / nrows)) + elif ncols is not None: + resolved_cols = ncols + resolved_rows = int(math.ceil(item_count / ncols)) + else: + resolved_rows = int(math.sqrt(item_count)) + resolved_cols = int(math.ceil(item_count / resolved_rows)) + + return PlotGrid( + item_count=item_count, + nrows=resolved_rows, + ncols=resolved_cols, + ) + + +def plot_frames( + frames: Sequence[np.ndarray], + nrows: int | None = None, + ncols: int | None = None, + *, + frames_are_bgr: bool = True, +) -> None: + from matplotlib import pyplot + + grid = get_plot_grid(len(frames), nrows=nrows, ncols=ncols) + fig, axes = pyplot.subplots( + grid.nrows, + grid.ncols, + figsize=_get_figsize(grid), + ) + flat_axes = np.atleast_1d(axes).reshape(-1) + + for axis, frame in zip(flat_axes, frames): + image = _prepare_frame(frame, grid) + if image.ndim != 2 and frames_are_bgr: + image = image[..., ::-1] + axis.imshow(image) + axis.set_axis_off() + + for axis in flat_axes[len(frames) :]: + axis.set_axis_off() + + fig.tight_layout() + pyplot.show() + pyplot.close(fig) + + +def _get_figsize(grid: PlotGrid) -> tuple[float, float]: + width = BASE_SUBPLOT_WIDTH * grid.ncols + height = BASE_SUBPLOT_HEIGHT * grid.nrows + scale = min(1.0, MAX_FIGURE_WIDTH / width, MAX_FIGURE_HEIGHT / height) + return width * scale, height * scale + + +def _prepare_frame(frame: np.ndarray, grid: PlotGrid) -> np.ndarray: + max_height = max(1, MAX_CANVAS_HEIGHT // grid.nrows) + max_width = max(1, MAX_CANVAS_WIDTH // grid.ncols) + height, width = frame.shape[:2] + stride = max(1, math.ceil(height / max_height), math.ceil(width / max_width)) + return frame[::stride, ::stride] diff --git a/src/opai/infrastructure/__init__.py b/src/opai/infrastructure/__init__.py index e67b9a8..eac5697 100644 --- a/src/opai/infrastructure/__init__.py +++ b/src/opai/infrastructure/__init__.py @@ -7,6 +7,8 @@ from opai.infrastructure.persistence import ( load_session_manifest, write_calibration_result, + write_charuco_board_config, + write_charuco_board_image, write_session_manifest, ) @@ -17,5 +19,7 @@ "list_session_names", "load_session_manifest", "write_calibration_result", + "write_charuco_board_config", + "write_charuco_board_image", "write_session_manifest", ] diff --git a/src/opai/infrastructure/persistence.py b/src/opai/infrastructure/persistence.py index 49e6149..2ac6abb 100644 --- a/src/opai/infrastructure/persistence.py +++ b/src/opai/infrastructure/persistence.py @@ -5,7 +5,11 @@ from collections.abc import Sequence from pathlib import Path -from opai.domain.calibration import CalibrationResult +import cv2 +import numpy as np + +from opai.core.exceptions import OPAIWorkflowError +from opai.domain.calibration import CalibrationResult, CharucoBoardConfig from opai.domain.session import DemoAsset, MappingAsset, SessionManifest @@ -37,6 +41,45 @@ def write_calibration_result( return output_path +def write_charuco_board_image( + session_directory: Path, + board_image: np.ndarray, + filename: str = "charuco_board.png", +) -> Path: + output_path = session_directory / filename + wrote_image = cv2.imwrite(str(output_path), board_image) + if not wrote_image: + raise OPAIWorkflowError( + "Failed to write the generated ChArUco board image.", + details={"path": str(output_path)}, + ) + return output_path + + +def write_charuco_board_config( + session_directory: Path, + config: CharucoBoardConfig, + *, + board_image_path: str, + filename: str = "charuco_config.json", +) -> Path: + payload = { + "dictionary": config.dictionary, + "squares_x": config.squares_x, + "squares_y": config.squares_y, + "square_length": config.square_length, + "marker_length": config.marker_length, + "image_width_px": config.image_width_px, + "image_height_px": config.image_height_px, + "margin_size_px": config.margin_size_px, + "board_image_path": board_image_path, + } + + output_path = session_directory / filename + output_path.write_text(json.dumps(payload, indent=2), encoding="utf-8") + return output_path + + def load_session_manifest(manifest_path: Path, session_name: str) -> SessionManifest: if not manifest_path.exists(): return SessionManifest(session_name=session_name, demos=(), mapping=None) diff --git a/src/opai/presentation/__init__.py b/src/opai/presentation/__init__.py index ab64a6e..4045d96 100644 --- a/src/opai/presentation/__init__.py +++ b/src/opai/presentation/__init__.py @@ -4,6 +4,7 @@ browse_session, calibrate, calibrate_with_video, + generate_charuco_board, get_context, init, list_sessions, @@ -17,6 +18,7 @@ "browse_session", "calibrate", "calibrate_with_video", + "generate_charuco_board", "get_context", "init", "list_sessions", diff --git a/src/opai/presentation/facade.py b/src/opai/presentation/facade.py index 4c36620..38a7bf4 100644 --- a/src/opai/presentation/facade.py +++ b/src/opai/presentation/facade.py @@ -11,9 +11,21 @@ OPAIDependencyError, OPAIValidationError, ) -from opai.domain.calibration import CalibrationResult +from opai.domain.calibration import ( + DEFAULT_CHARUCO_DICTIONARY, + DEFAULT_CHARUCO_IMAGE_HEIGHT_PX, + DEFAULT_CHARUCO_IMAGE_WIDTH_PX, + DEFAULT_CHARUCO_MARGIN_SIZE_PX, + DEFAULT_CHARUCO_MARKER_LENGTH, + DEFAULT_CHARUCO_SQUARE_LENGTH, + DEFAULT_CHARUCO_SQUARES_X, + DEFAULT_CHARUCO_SQUARES_Y, + CalibrationResult, + CharucoBoardArtifacts, + CharucoBoardConfig, +) from opai.domain.context import Context -from opai.domain.grid import get_plot_grid +from opai.domain.plot import plot_frames from opai.domain.session import DemoAsset, MappingAsset from opai.infrastructure.context_store import get_active_context, init_context @@ -48,6 +60,9 @@ def calibrate( square_length: float, marker_length: float, dictionary: str, + nrows: int | None = None, + ncols: int | None = None, + plot_result: bool = False, ) -> CalibrationResult: ctx = get_context() try: @@ -66,9 +81,46 @@ def calibrate( square_length=square_length, marker_length=marker_length, dictionary=dictionary, + nrows=nrows, + ncols=ncols, + plot_result=plot_result, ) +def generate_charuco_board( + dictionary: str = DEFAULT_CHARUCO_DICTIONARY, + squares_x: int = DEFAULT_CHARUCO_SQUARES_X, + squares_y: int = DEFAULT_CHARUCO_SQUARES_Y, + square_length: float = DEFAULT_CHARUCO_SQUARE_LENGTH, + marker_length: float = DEFAULT_CHARUCO_MARKER_LENGTH, + image_width_px: int = DEFAULT_CHARUCO_IMAGE_WIDTH_PX, + image_height_px: int = DEFAULT_CHARUCO_IMAGE_HEIGHT_PX, + margin_size_px: int = DEFAULT_CHARUCO_MARGIN_SIZE_PX, +) -> CharucoBoardArtifacts: + ctx = get_context() + try: + from opai.application.calibration import ( + generate_charuco_board as generate_charuco_board_with_context, + ) + except ModuleNotFoundError as exc: + raise OPAIDependencyError( + "ChArUco board generation dependencies are unavailable. Install the project's " + "OpenCV stack before calling opai.generate_charuco_board(...)." + ) from exc + + config = CharucoBoardConfig( + dictionary=dictionary, + squares_x=squares_x, + squares_y=squares_y, + square_length=square_length, + marker_length=marker_length, + image_width_px=image_width_px, + image_height_px=image_height_px, + margin_size_px=margin_size_px, + ) + return generate_charuco_board_with_context(ctx=ctx, config=config) + + def calibrate_with_video( video_path: str | Path, frame_sample_step: int, @@ -77,6 +129,9 @@ def calibrate_with_video( square_length: float, marker_length: float, dictionary: str, + nrows: int | None = None, + ncols: int | None = None, + plot_result: bool = False, ) -> CalibrationResult: ctx = get_context() try: @@ -104,6 +159,9 @@ def calibrate_with_video( square_length=square_length, marker_length=marker_length, dictionary=dictionary, + nrows=nrows, + ncols=ncols, + plot_result=plot_result, ) @@ -128,38 +186,20 @@ def plot_video_frames( frame_sample_step=frame_sample_step, ) try: - import matplotlib.pyplot as pyplot + plot_frames( + frames, + nrows=nrows, + ncols=ncols, + frames_are_bgr=True, + ) except ModuleNotFoundError as exc: raise OPAIDependencyError( "Frame plotting requires the 'matplotlib' package. Install project " "dependencies before calling opai.plot_video_frames(...)." ) from exc - - try: - grid = get_plot_grid(len(frames), nrows=nrows, ncols=ncols) except ValueError as exc: raise OPAIValidationError(str(exc)) from exc - fig, axes = pyplot.subplots( - grid.nrows, - grid.ncols, - figsize=(4 * grid.ncols, 3 * grid.nrows), - ) - flat_axes = np.atleast_1d(axes).reshape(-1) - - for axis, frame in zip(flat_axes, frames): - if frame.ndim == 2: - axis.imshow(frame) - else: - axis.imshow(frame[:, :, ::-1]) - axis.set_axis_off() - - for axis in flat_axes[len(frames) :]: - axis.set_axis_off() - - fig.tight_layout() - pyplot.show() - def add_demos(video_paths: Sequence[str | Path]) -> tuple[DemoAsset, ...]: ctx = get_context() @@ -266,6 +306,6 @@ def browse_session(name: str) -> list[str]: def main() -> None: print( "Use opai.init(name), opai.add_demos(...), opai.add_mapping(...), " - "opai.calibrate(...), opai.calibrate_with_video(...), and " - "opai.plot_video_frames(...) from Python." + "opai.generate_charuco_board(...), opai.calibrate(...), " + "opai.calibrate_with_video(...), and opai.plot_video_frames(...) from Python." ) diff --git a/tests/test_calibration.py b/tests/test_calibration.py index f4b241d..69cea51 100644 --- a/tests/test_calibration.py +++ b/tests/test_calibration.py @@ -7,15 +7,15 @@ from opai.application import calibration as calibration_module from opai.application.calibration import ( + _build_fisheye_calibration_points, _build_intrinsics, _compute_mse_reprojection_error, - _resolve_dictionary, calibrate, sample_video_frames, ) from opai.core.exceptions import OPAIValidationError, OPAIWorkflowError from opai.domain.context import Context -from opai.domain.grid import get_plot_grid +from opai.domain.plot import get_plot_grid from opai.infrastructure import video as video_module @@ -34,7 +34,24 @@ def fake_cv2(monkeypatch: pytest.MonkeyPatch) -> SimpleNamespace: DICT_4X4_50=1, getPredefinedDictionary=lambda dictionary_id: {"id": dictionary_id}, ), - projectPoints=lambda **kwargs: (kwargs["objectPoints"][:, :, :2], None), + fisheye=SimpleNamespace( + CALIB_RECOMPUTE_EXTRINSIC=1, + CALIB_CHECK_COND=2, + CALIB_FIX_SKEW=4, + calibrate=lambda *args, **kwargs: ( + 0.1, + np.eye(3), + np.zeros((4, 1)), + (), + (), + ), + projectPoints=lambda object_points, *_args: ( + object_points[:, :, :2], + None, + ), + ), + TERM_CRITERIA_EPS=1, + TERM_CRITERIA_MAX_ITER=2, COLOR_BGR2GRAY=1, cvtColor=lambda frame, _: frame[:, :, 0], ) @@ -42,9 +59,15 @@ def fake_cv2(monkeypatch: pytest.MonkeyPatch) -> SimpleNamespace: return fake -def test_resolve_dictionary_rejects_unknown_name(fake_cv2: SimpleNamespace) -> None: +def test_calibrate_rejects_unknown_dictionary( + tmp_path, + fake_cv2: SimpleNamespace, +) -> None: + ctx = Context(name="session", session_directory=tmp_path) + frame = np.zeros((10, 10, 3), dtype=np.uint8) + with pytest.raises(OPAIValidationError, match="Unsupported ArUco dictionary"): - _resolve_dictionary("DICT_DOES_NOT_EXIST") + calibrate(ctx, [frame], 3, 3, 1.0, 0.5, "DICT_DOES_NOT_EXIST") def test_calibrate_rejects_invalid_frames(tmp_path, fake_cv2: SimpleNamespace) -> None: @@ -56,6 +79,56 @@ def test_calibrate_rejects_invalid_frames(tmp_path, fake_cv2: SimpleNamespace) - calibrate(ctx, [frame_a, frame_b], 3, 3, 1.0, 0.5, "DICT_4X4_50") +def test_build_fisheye_calibration_points_maps_ids_to_board_coordinates() -> None: + board = DummyBoard( + np.array( + [ + [0.0, 0.0, 0.0], + [1.0, 0.0, 0.0], + [0.0, 1.0, 0.0], + ], + dtype=np.float32, + ) + ) + charuco_corners = [ + np.array( + [ + [[10.0, 20.0]], + [[30.0, 40.0]], + ], + dtype=np.float32, + ) + ] + charuco_ids = [np.array([[2], [0]], dtype=np.int32)] + + object_points, image_points = _build_fisheye_calibration_points( + board=board, + charuco_corners=charuco_corners, + charuco_ids=charuco_ids, + ) + + assert np.array_equal( + object_points[0], + np.array( + [ + [[0.0, 1.0, 0.0]], + [[0.0, 0.0, 0.0]], + ], + dtype=np.float64, + ), + ) + assert np.array_equal( + image_points[0], + np.array( + [ + [[10.0, 20.0]], + [[30.0, 40.0]], + ], + dtype=np.float64, + ), + ) + + def test_build_intrinsics_zero_fills_missing_distortion() -> None: camera_matrix = np.array([[10.0, 1.5, 5.0], [0.0, 20.0, 6.0], [0.0, 0.0, 1.0]]) dist_coeffs = np.array([0.1, 0.2]) @@ -83,13 +156,19 @@ def test_compute_mse_reprojection_error_averages_squared_pixel_error( observed_corners = [np.array([[[1.0, 1.0]], [[4.0, 4.0]]], dtype=np.float32)] observed_ids = [np.array([[0], [1]], dtype=np.int32)] - def fake_project_points(**_: np.ndarray) -> tuple[np.ndarray, None]: + def fake_project_points( + object_points: np.ndarray, + _rvec: np.ndarray, + _tvec: np.ndarray, + _camera_matrix: np.ndarray, + _dist_coeffs: np.ndarray, + ) -> tuple[np.ndarray, None]: return np.array([[[2.0, 3.0]], [[5.0, 6.0]]], dtype=np.float32), None monkeypatch.setattr( calibration_module, "cv2", - SimpleNamespace(projectPoints=fake_project_points), + SimpleNamespace(fisheye=SimpleNamespace(projectPoints=fake_project_points)), ) mse = _compute_mse_reprojection_error( diff --git a/tests/test_charuco.py b/tests/test_charuco.py new file mode 100644 index 0000000..c7798e7 --- /dev/null +++ b/tests/test_charuco.py @@ -0,0 +1,124 @@ +from __future__ import annotations + +import json +from types import SimpleNamespace + +import numpy as np +import pytest + +import opai +from opai.application import calibration as calibration_module +from opai.core.exceptions import OPAIContextError, OPAIValidationError +from opai.domain.calibration import CharucoBoardConfig, validate_charuco_board_config +from opai.infrastructure import context_store +from opai.infrastructure import persistence as persistence_module + + +@pytest.fixture(autouse=True) +def reset_active_context(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(context_store, "_ACTIVE_CONTEXT", None) + + +def test_validate_charuco_board_config_rejects_invalid_marker_length() -> None: + config = CharucoBoardConfig( + dictionary="DICT_5X5_100", + squares_x=11, + squares_y=8, + square_length=0.02, + marker_length=0.02, + image_width_px=2000, + image_height_px=1400, + margin_size_px=20, + ) + + with pytest.raises(OPAIValidationError, match="smaller than square_length"): + validate_charuco_board_config(config) + + +def test_generate_charuco_board_requires_context(monkeypatch) -> None: + monkeypatch.setattr(context_store, "_ACTIVE_CONTEXT", None) + + with pytest.raises(OPAIContextError, match="Call opai.init"): + opai.generate_charuco_board() + + +def test_generate_charuco_board_writes_image_and_config(tmp_path, monkeypatch) -> None: + monkeypatch.chdir(tmp_path) + opai.init("session-001") + fake_cv2 = _build_fake_cv2() + monkeypatch.setattr(calibration_module, "cv2", fake_cv2) + monkeypatch.setattr(persistence_module, "cv2", fake_cv2) + + artifacts = opai.generate_charuco_board() + + image_path = tmp_path / ".opai_sessions" / "session-001" / "charuco_board.png" + config_path = tmp_path / ".opai_sessions" / "session-001" / "charuco_config.json" + + assert artifacts.image_path == image_path + assert artifacts.config_path == config_path + assert image_path.read_bytes() == b"fake-png" + + payload = json.loads(config_path.read_text(encoding="utf-8")) + assert payload == { + "dictionary": "DICT_5X5_100", + "squares_x": 11, + "squares_y": 8, + "square_length": 0.03, + "marker_length": 0.022, + "image_width_px": 2000, + "image_height_px": 1400, + "margin_size_px": 20, + "board_image_path": "charuco_board.png", + } + + +def test_generate_charuco_board_rejects_unknown_dictionary( + tmp_path, monkeypatch +) -> None: + monkeypatch.chdir(tmp_path) + opai.init("session-001") + fake_cv2 = _build_fake_cv2() + monkeypatch.setattr(calibration_module, "cv2", fake_cv2) + monkeypatch.setattr(persistence_module, "cv2", fake_cv2) + + with pytest.raises(OPAIValidationError, match="Unsupported ArUco dictionary"): + opai.generate_charuco_board(dictionary="DICT_DOES_NOT_EXIST") + + +def _build_fake_cv2() -> SimpleNamespace: + class FakeBoard: + def __init__( + self, + size: tuple[int, int], + square_length: float, + marker_length: float, + dictionary: object, + ) -> None: + self.size = size + self.square_length = square_length + self.marker_length = marker_length + self.dictionary = dictionary + + def generateImage( + self, + image_size: tuple[int, int], + *, + marginSize: int, + ) -> np.ndarray: + width, height = image_size + return np.full((height, width), fill_value=marginSize, dtype=np.uint8) + + def fake_imwrite(path: str, _image: np.ndarray) -> bool: + from pathlib import Path + + Path(path).write_bytes(b"fake-png") + return True + + return SimpleNamespace( + aruco=SimpleNamespace( + DICT_5X5_100=100, + getPredefinedDictionary=lambda dictionary_id: {"id": dictionary_id}, + CharucoBoard=FakeBoard, + ), + imwrite=fake_imwrite, + ) diff --git a/tests/test_facade.py b/tests/test_facade.py index 09e4487..b908eef 100644 --- a/tests/test_facade.py +++ b/tests/test_facade.py @@ -82,11 +82,20 @@ def test_init_rejects_invalid_session_name() -> None: opai.init("../bad-session") -def test_calibrate_writes_artifact(tmp_path, monkeypatch) -> None: +def test_calibrate_writes_artifact_without_plotting_by_default( + tmp_path, monkeypatch +) -> None: fake_cv2 = _build_fake_cv2() monkeypatch.setattr(calibration_module, "cv2", fake_cv2) monkeypatch.chdir(tmp_path) opai.init("session-001") + monkeypatch.setattr( + calibration_module, + "plot_frames", + lambda *_args, **_kwargs: pytest.fail( + "plot_frames should not run when plot_result=False" + ), + ) frame = np.zeros((10, 12, 3), dtype=np.uint8) result = opai.calibrate( @@ -107,7 +116,32 @@ def test_calibrate_writes_artifact(tmp_path, monkeypatch) -> None: assert result.intrinsic_type == "FISHEYE" -def test_calibrate_with_video_writes_artifact_without_plotting( +def test_calibrate_accepts_custom_plot_grid(tmp_path, monkeypatch) -> None: + fake_cv2 = _build_fake_cv2() + monkeypatch.setattr(calibration_module, "cv2", fake_cv2) + monkeypatch.chdir(tmp_path) + opai.init("session-001") + pyplot = _build_fake_pyplot(nrows=1, ncols=5) + monkeypatch.setitem(sys.modules, "matplotlib", SimpleNamespace(pyplot=pyplot)) + monkeypatch.setitem(sys.modules, "matplotlib.pyplot", pyplot) + + frame = np.zeros((10, 12, 3), dtype=np.uint8) + opai.calibrate( + [frame], + 3, + 3, + 1.0, + 0.5, + "DICT_4X4_50", + nrows=1, + ncols=5, + plot_result=True, + ) + + assert pyplot.subplots_calls == [((1, 5), {"figsize": (16.0, 2.4000000000000004)})] + + +def test_calibrate_with_video_writes_artifact_without_plotting_by_default( tmp_path, monkeypatch, ) -> None: @@ -126,13 +160,56 @@ def test_calibrate_with_video_writes_artifact_without_plotting( "sample_video_frames", lambda **kwargs: frames, ) + monkeypatch.setattr( + calibration_module, + "plot_frames", + lambda *_args, **_kwargs: pytest.fail( + "plot_frames should not run when plot_result=False" + ), + ) - def fail_matplotlib_import(name, globals=None, locals=None, fromlist=(), level=0): - if name == "matplotlib.pyplot": - pytest.fail("calibrate_with_video should not import matplotlib") - return builtin_import(name, globals, locals, fromlist, level) + result = opai.calibrate_with_video( + video_path=video_path, + frame_sample_step=2, + row_count=3, + col_count=3, + square_length=1.0, + marker_length=0.5, + dictionary="DICT_4X4_50", + nrows=1, + ncols=5, + ) - monkeypatch.setattr("builtins.__import__", fail_matplotlib_import) + output_path = tmp_path / ".opai_sessions" / "session-001" / "calibration.json" + assert output_path.exists() + payload = json.loads(output_path.read_text(encoding="utf-8")) + assert payload["image_height"] == 10 + assert payload["image_width"] == 12 + assert result.intrinsic_type == "FISHEYE" + + +def test_calibrate_with_video_plots_detected_corners_when_enabled( + tmp_path, + monkeypatch, +) -> None: + fake_cv2 = _build_fake_cv2() + monkeypatch.setattr(calibration_module, "cv2", fake_cv2) + monkeypatch.chdir(tmp_path) + opai.init("session-001") + + frames = tuple( + np.full((10, 12, 3), fill_value=index, dtype=np.uint8) for index in range(5) + ) + video_path = tmp_path / "demo.mp4" + video_path.write_bytes(b"demo") + monkeypatch.setattr( + calibration_module, + "sample_video_frames", + lambda **kwargs: frames, + ) + pyplot = _build_fake_pyplot(nrows=1, ncols=5) + monkeypatch.setitem(sys.modules, "matplotlib", SimpleNamespace(pyplot=pyplot)) + monkeypatch.setitem(sys.modules, "matplotlib.pyplot", pyplot) result = opai.calibrate_with_video( video_path=video_path, @@ -142,6 +219,9 @@ def fail_matplotlib_import(name, globals=None, locals=None, fromlist=(), level=0 square_length=1.0, marker_length=0.5, dictionary="DICT_4X4_50", + nrows=1, + ncols=5, + plot_result=True, ) output_path = tmp_path / ".opai_sessions" / "session-001" / "calibration.json" @@ -150,6 +230,50 @@ def fail_matplotlib_import(name, globals=None, locals=None, fromlist=(), level=0 assert payload["image_height"] == 10 assert payload["image_width"] == 12 assert result.intrinsic_type == "FISHEYE" + assert pyplot.subplots_calls == [((1, 5), {"figsize": (16.0, 2.4000000000000004)})] + assert pyplot.show_count == 1 + assert pyplot.close_calls == [pyplot.figure] + assert np.array_equal(pyplot.axes[0].images[0], frames[0][..., ::-1]) + + +def test_calibrate_with_video_requires_matplotlib_for_detected_corner_plotting( + tmp_path, + monkeypatch, +) -> None: + fake_cv2 = _build_fake_cv2() + monkeypatch.setattr(calibration_module, "cv2", fake_cv2) + monkeypatch.chdir(tmp_path) + opai.init("session-001") + + frames = tuple( + np.full((10, 12, 3), fill_value=index, dtype=np.uint8) for index in range(2) + ) + video_path = tmp_path / "demo.mp4" + video_path.write_bytes(b"demo") + monkeypatch.setattr( + calibration_module, + "sample_video_frames", + lambda **kwargs: frames, + ) + + def fake_import(name, globals=None, locals=None, fromlist=(), level=0): + if name == "matplotlib": + raise ModuleNotFoundError("No module named 'matplotlib'") + return builtin_import(name, globals, locals, fromlist, level) + + monkeypatch.setattr("builtins.__import__", fake_import) + + with pytest.raises(OPAIDependencyError, match="matplotlib"): + opai.calibrate_with_video( + video_path=video_path, + frame_sample_step=2, + row_count=3, + col_count=3, + square_length=1.0, + marker_length=0.5, + dictionary="DICT_4X4_50", + plot_result=True, + ) def test_plot_video_frames_uses_auto_grid_defaults_without_context( @@ -173,13 +297,15 @@ def test_plot_video_frames_uses_auto_grid_defaults_without_context( opai.plot_video_frames(video_path, frame_sample_step=2) - assert pyplot.subplots_calls == [((2, 3), {"figsize": (12, 6)})] + assert pyplot.subplots_calls == [((2, 3), {"figsize": (12.0, 6.0)})] assert pyplot.show_count == 1 + assert pyplot.close_calls == [pyplot.figure] assert pyplot.figure.tight_layout_calls == 1 assert np.array_equal( pyplot.axes[0].images[0], - frames[0][:, :, ::-1], + frames[0][..., ::-1], ) + assert np.shares_memory(pyplot.axes[0].images[0], frames[0]) assert all(axis.axis_off_calls == 1 for axis in pyplot.axes[:5]) assert pyplot.axes[5].axis_off_calls == 1 @@ -201,8 +327,10 @@ def test_plot_video_frames_accepts_custom_grid(tmp_path, monkeypatch) -> None: opai.plot_video_frames(video_path, frame_sample_step=2, nrows=1, ncols=5) - assert pyplot.subplots_calls == [((1, 5), {"figsize": (20, 3)})] + assert pyplot.subplots_calls[0][0] == (1, 5) + assert pyplot.subplots_calls[0][1]["figsize"] == pytest.approx((16.0, 2.4)) assert pyplot.show_count == 1 + assert pyplot.close_calls == [pyplot.figure] assert all(axis.axis_off_calls == 1 for axis in pyplot.axes) @@ -216,7 +344,7 @@ def test_plot_video_frames_requires_matplotlib(tmp_path, monkeypatch) -> None: ) def fake_import(name, globals=None, locals=None, fromlist=(), level=0): - if name == "matplotlib.pyplot": + if name == "matplotlib": raise ModuleNotFoundError("No module named 'matplotlib'") return builtin_import(name, globals, locals, fromlist, level) @@ -241,6 +369,29 @@ def test_plot_video_frames_propagates_sampling_failure(tmp_path, monkeypatch) -> opai.plot_video_frames(video_path, frame_sample_step=2) +def test_plot_video_frames_downsamples_frames_before_plotting( + tmp_path, + monkeypatch, +) -> None: + frame = np.arange(2000 * 3000 * 3, dtype=np.uint8).reshape(2000, 3000, 3) + video_path = tmp_path / "demo.mp4" + video_path.write_bytes(b"demo") + monkeypatch.setattr( + calibration_module, + "sample_video_frames", + lambda **kwargs: (frame,), + ) + pyplot = _build_fake_pyplot(nrows=1, ncols=1) + monkeypatch.setitem(sys.modules, "matplotlib", SimpleNamespace(pyplot=pyplot)) + monkeypatch.setitem(sys.modules, "matplotlib.pyplot", pyplot) + + opai.plot_video_frames(video_path, frame_sample_step=2) + + plotted = pyplot.axes[0].images[0] + assert plotted.shape == (1000, 1500, 3) + assert np.shares_memory(plotted, frame) + + def test_add_demos_requires_context(tmp_path, monkeypatch) -> None: monkeypatch.chdir(tmp_path) monkeypatch.setattr(context_store, "_ACTIVE_CONTEXT", None) @@ -393,12 +544,27 @@ def fake_import(name, globals=None, locals=None, fromlist=(), level=0): def _build_fake_cv2() -> SimpleNamespace: - def fake_calibrate_camera_charuco(**kwargs): - frame_count = len(kwargs["charucoCorners"]) + def fake_fisheye_calibrate( + object_points, + image_points, + image_size, + camera_matrix, + dist_coeffs, + *, + flags, + criteria, + ): + frame_count = len(object_points) + assert len(image_points) == frame_count + assert image_size == (12, 10) + assert camera_matrix.shape == (3, 3) + assert dist_coeffs.shape == (4, 1) + assert flags == 7 + assert criteria == (3, 100, 1e-6) return ( 0.1, np.array([[10.0, 0.0, 5.0], [0.0, 20.0, 6.0], [0.0, 0.0, 1.0]]), - np.array([0.1, 0.2, 0.3, 0.4]), + np.array([[0.1], [0.2], [0.3], [0.4]]), [np.zeros((3, 1), dtype=np.float32) for _ in range(frame_count)], [np.zeros((3, 1), dtype=np.float32) for _ in range(frame_count)], ) @@ -415,36 +581,55 @@ def fake_calibrate_camera_charuco(**kwargs): ) ) + detected_corners = np.array( + [ + [[1.0, 1.0]], + [[2.0, 1.0]], + [[1.0, 2.0]], + [[2.0, 2.0]], + ], + dtype=np.float32, + ) + detected_ids = np.array([[0], [1], [2], [3]], dtype=np.int32) + + class FakeCharucoDetector: + def __init__(self, _board) -> None: + self.board = _board + + def detectBoard(self, _image): + return detected_corners, detected_ids, None, None + + def fake_cvt_color(frame: np.ndarray, code: int) -> np.ndarray: + if code == 1: + return frame[:, :, 0] + if code == 2: + return np.repeat(frame[:, :, None], 3, axis=2) + raise AssertionError(f"Unexpected color conversion code: {code}") + aruco = SimpleNamespace( DICT_4X4_50=1, getPredefinedDictionary=lambda _: "dictionary", CharucoBoard=lambda *args, **kwargs: board, - detectMarkers=lambda *args, **kwargs: ( - [np.zeros((4, 1, 2), dtype=np.float32)], - np.array([[0]], dtype=np.int32), - None, - ), - interpolateCornersCharuco=lambda **kwargs: ( - 4, - np.array( - [ - [[1.0, 1.0]], - [[2.0, 1.0]], - [[1.0, 2.0]], - [[2.0, 2.0]], - ], - dtype=np.float32, - ), - np.array([[0], [1], [2], [3]], dtype=np.int32), - ), - calibrateCameraCharuco=fake_calibrate_camera_charuco, + CharucoDetector=FakeCharucoDetector, + drawDetectedCornersCharuco=lambda **kwargs: kwargs["image"], + ) + fisheye = SimpleNamespace( + CALIB_RECOMPUTE_EXTRINSIC=1, + CALIB_CHECK_COND=2, + CALIB_FIX_SKEW=4, + calibrate=fake_fisheye_calibrate, + projectPoints=lambda object_points, *_args: (object_points[:, :, :2], None), ) return SimpleNamespace( aruco=aruco, + fisheye=fisheye, + TERM_CRITERIA_EPS=1, + TERM_CRITERIA_MAX_ITER=2, COLOR_BGR2GRAY=1, - cvtColor=lambda frame, _: frame[:, :, 0], - projectPoints=lambda **kwargs: (kwargs["objectPoints"][:, :, :2], None), + COLOR_GRAY2BGR=2, + cvtColor=fake_cvt_color, + waitKey=lambda _delay: 0, ) @@ -500,10 +685,9 @@ class FakePyplot: def __init__(self) -> None: self.figure = FakeFigure() self.axes = [FakeAxis() for _ in range(nrows * ncols)] - self.subplots_calls: list[ - tuple[tuple[int, int], dict[str, tuple[int, int]]] - ] = [] + self.subplots_calls: list[tuple[tuple[int, int], dict[str, object]]] = [] self.show_count = 0 + self.close_calls: list[FakeFigure] = [] def subplots(self, rows: int, cols: int, **kwargs): self.subplots_calls.append(((rows, cols), kwargs)) @@ -512,4 +696,7 @@ def subplots(self, rows: int, cols: int, **kwargs): def show(self) -> None: self.show_count += 1 + def close(self, figure) -> None: + self.close_calls.append(figure) + return FakePyplot() From 286b816f3393af95abc39dc5d2c71df562a722a7 Mon Sep 17 00:00:00 2001 From: author31 Date: Fri, 20 Mar 2026 15:36:19 +0800 Subject: [PATCH 6/8] update plot figsize --- src/opai/application/calibration.py | 9 ++++---- src/opai/domain/plot.py | 34 ++++++++++++----------------- src/opai/presentation/facade.py | 16 +++++++------- tests/test_facade.py | 25 +++++++++++---------- 4 files changed, 39 insertions(+), 45 deletions(-) diff --git a/src/opai/application/calibration.py b/src/opai/application/calibration.py index 935ddd7..e720020 100644 --- a/src/opai/application/calibration.py +++ b/src/opai/application/calibration.py @@ -38,9 +38,9 @@ def calibrate( square_length: float, marker_length: float, dictionary: str, - nrows: int | None = None, - ncols: int | None = None, plot_result: bool = False, + plot_nrows: int | None = None, + plot_ncols: int | None = None, ) -> CalibrationResult: _validate_inputs( frames=frames, @@ -70,7 +70,6 @@ def calibrate( grayscale = ( frame if frame.ndim == 2 else cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) ) - cv2.waitKey(0) charuco_corners, charuco_ids, _, _ = charuco_detector.detectBoard(grayscale) if charuco_ids is None or charuco_corners is None: @@ -101,8 +100,8 @@ def calibrate( try: plot_frames( detected_corner_frames, - nrows=nrows, - ncols=ncols, + nrows=plot_nrows, + ncols=plot_ncols, frames_are_bgr=True, ) except ModuleNotFoundError as exc: diff --git a/src/opai/domain/plot.py b/src/opai/domain/plot.py index bfb29b6..9da3378 100644 --- a/src/opai/domain/plot.py +++ b/src/opai/domain/plot.py @@ -6,12 +6,13 @@ import numpy as np -BASE_SUBPLOT_WIDTH = 4.0 +BASE_SUBPLOT_WIDTH = 8.0 BASE_SUBPLOT_HEIGHT = 3.0 MAX_FIGURE_WIDTH = 16.0 MAX_FIGURE_HEIGHT = 12.0 -MAX_CANVAS_WIDTH = 1600 -MAX_CANVAS_HEIGHT = 1200 +MIN_SINGLE_ROW_FIGURE_HEIGHT = 4.5 +MAX_CANVAS_WIDTH = 4072 +MAX_CANVAS_HEIGHT = 2304 @dataclass(frozen=True) @@ -68,10 +69,9 @@ def plot_frames( flat_axes = np.atleast_1d(axes).reshape(-1) for axis, frame in zip(flat_axes, frames): - image = _prepare_frame(frame, grid) - if image.ndim != 2 and frames_are_bgr: - image = image[..., ::-1] - axis.imshow(image) + if frame.ndim != 2 and frames_are_bgr: + frame = frame[..., ::-1] + axis.imshow(frame) axis.set_axis_off() for axis in flat_axes[len(frames) :]: @@ -82,16 +82,10 @@ def plot_frames( pyplot.close(fig) -def _get_figsize(grid: PlotGrid) -> tuple[float, float]: - width = BASE_SUBPLOT_WIDTH * grid.ncols - height = BASE_SUBPLOT_HEIGHT * grid.nrows - scale = min(1.0, MAX_FIGURE_WIDTH / width, MAX_FIGURE_HEIGHT / height) - return width * scale, height * scale - - -def _prepare_frame(frame: np.ndarray, grid: PlotGrid) -> np.ndarray: - max_height = max(1, MAX_CANVAS_HEIGHT // grid.nrows) - max_width = max(1, MAX_CANVAS_WIDTH // grid.ncols) - height, width = frame.shape[:2] - stride = max(1, math.ceil(height / max_height), math.ceil(width / max_width)) - return frame[::stride, ::stride] +def _get_figsize( + grid: PlotGrid, + *, + imsize: float = 3.0, + add_vert: float = 0.6, +) -> tuple[float, float]: + return grid.ncols * imsize, grid.nrows * imsize + add_vert diff --git a/src/opai/presentation/facade.py b/src/opai/presentation/facade.py index 38a7bf4..3ee7d57 100644 --- a/src/opai/presentation/facade.py +++ b/src/opai/presentation/facade.py @@ -60,9 +60,9 @@ def calibrate( square_length: float, marker_length: float, dictionary: str, - nrows: int | None = None, - ncols: int | None = None, plot_result: bool = False, + plot_nrows: int | None = None, + plot_ncols: int | None = None, ) -> CalibrationResult: ctx = get_context() try: @@ -81,9 +81,9 @@ def calibrate( square_length=square_length, marker_length=marker_length, dictionary=dictionary, - nrows=nrows, - ncols=ncols, plot_result=plot_result, + plot_nrows=plot_nrows, + plot_ncols=plot_ncols, ) @@ -129,9 +129,9 @@ def calibrate_with_video( square_length: float, marker_length: float, dictionary: str, - nrows: int | None = None, - ncols: int | None = None, plot_result: bool = False, + plot_nrows: int | None = None, + plot_ncols: int | None = None, ) -> CalibrationResult: ctx = get_context() try: @@ -159,9 +159,9 @@ def calibrate_with_video( square_length=square_length, marker_length=marker_length, dictionary=dictionary, - nrows=nrows, - ncols=ncols, plot_result=plot_result, + plot_nrows=plot_nrows, + plot_ncols=plot_ncols, ) diff --git a/tests/test_facade.py b/tests/test_facade.py index b908eef..02f8798 100644 --- a/tests/test_facade.py +++ b/tests/test_facade.py @@ -133,12 +133,12 @@ def test_calibrate_accepts_custom_plot_grid(tmp_path, monkeypatch) -> None: 1.0, 0.5, "DICT_4X4_50", - nrows=1, - ncols=5, plot_result=True, + plot_nrows=1, + plot_ncols=5, ) - assert pyplot.subplots_calls == [((1, 5), {"figsize": (16.0, 2.4000000000000004)})] + assert pyplot.subplots_calls == [((1, 5), {"figsize": (16.0, 4.5)})] def test_calibrate_with_video_writes_artifact_without_plotting_by_default( @@ -176,8 +176,9 @@ def test_calibrate_with_video_writes_artifact_without_plotting_by_default( square_length=1.0, marker_length=0.5, dictionary="DICT_4X4_50", - nrows=1, - ncols=5, + plot_result=False, + plot_nrows=1, + plot_ncols=5, ) output_path = tmp_path / ".opai_sessions" / "session-001" / "calibration.json" @@ -219,9 +220,9 @@ def test_calibrate_with_video_plots_detected_corners_when_enabled( square_length=1.0, marker_length=0.5, dictionary="DICT_4X4_50", - nrows=1, - ncols=5, plot_result=True, + plot_nrows=1, + plot_ncols=5, ) output_path = tmp_path / ".opai_sessions" / "session-001" / "calibration.json" @@ -230,7 +231,7 @@ def test_calibrate_with_video_plots_detected_corners_when_enabled( assert payload["image_height"] == 10 assert payload["image_width"] == 12 assert result.intrinsic_type == "FISHEYE" - assert pyplot.subplots_calls == [((1, 5), {"figsize": (16.0, 2.4000000000000004)})] + assert pyplot.subplots_calls == [((1, 5), {"figsize": (16.0, 4.5)})] assert pyplot.show_count == 1 assert pyplot.close_calls == [pyplot.figure] assert np.array_equal(pyplot.axes[0].images[0], frames[0][..., ::-1]) @@ -328,7 +329,7 @@ def test_plot_video_frames_accepts_custom_grid(tmp_path, monkeypatch) -> None: opai.plot_video_frames(video_path, frame_sample_step=2, nrows=1, ncols=5) assert pyplot.subplots_calls[0][0] == (1, 5) - assert pyplot.subplots_calls[0][1]["figsize"] == pytest.approx((16.0, 2.4)) + assert pyplot.subplots_calls[0][1]["figsize"] == pytest.approx((16.0, 4.5)) assert pyplot.show_count == 1 assert pyplot.close_calls == [pyplot.figure] assert all(axis.axis_off_calls == 1 for axis in pyplot.axes) @@ -369,11 +370,11 @@ def test_plot_video_frames_propagates_sampling_failure(tmp_path, monkeypatch) -> opai.plot_video_frames(video_path, frame_sample_step=2) -def test_plot_video_frames_downsamples_frames_before_plotting( +def test_plot_video_frames_downsamples_oversized_frames_before_plotting( tmp_path, monkeypatch, ) -> None: - frame = np.arange(2000 * 3000 * 3, dtype=np.uint8).reshape(2000, 3000, 3) + frame = np.arange(4000 * 6000 * 3, dtype=np.uint8).reshape(4000, 6000, 3) video_path = tmp_path / "demo.mp4" video_path.write_bytes(b"demo") monkeypatch.setattr( @@ -388,7 +389,7 @@ def test_plot_video_frames_downsamples_frames_before_plotting( opai.plot_video_frames(video_path, frame_sample_step=2) plotted = pyplot.axes[0].images[0] - assert plotted.shape == (1000, 1500, 3) + assert plotted.shape == (2000, 3000, 3) assert np.shares_memory(plotted, frame) From d0135dafdd293c4d3a4e9f6375365015a5af1b34 Mon Sep 17 00:00:00 2001 From: author31 Date: Fri, 20 Mar 2026 18:22:02 +0800 Subject: [PATCH 7/8] add verify_calibrated_parameters --- AGENTS.md | 1 + src/opai/__init__.py | 2 + src/opai/application/__init__.py | 7 +- src/opai/application/calibration.py | 401 +++++++++++++++++++++++++ src/opai/domain/__init__.py | 4 + src/opai/domain/calibration.py | 18 ++ src/opai/domain/plot.py | 2 +- src/opai/infrastructure/__init__.py | 4 + src/opai/infrastructure/persistence.py | 35 ++- src/opai/presentation/__init__.py | 2 + src/opai/presentation/facade.py | 30 +- tests/test_calibration.py | 297 ++++++++++++++++++ tests/test_facade.py | 148 ++++++++- 13 files changed, 946 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 8f73f43..4c12a32 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -144,6 +144,7 @@ Outcome: - Make artifact creation deterministic where possible. - Make errors notebook-friendly and actionable. - Fail fast on required dependencies. If a feature depends on a package that is required by this repo, import it normally and let missing dependencies fail at import time rather than adding deferred runtime guards. Reserve lazy imports or fallback guards for truly optional integrations only. +- Do not wrap calls that rely on required dependencies in local `ModuleNotFoundError` handling. Call them directly and let the required dependency failure surface naturally. - In `src/opai/presentation/facade.py`, prefer comprehensive public-function implementations over underscore-prefixed helper members. Keep the facade logic visible in the public functions rather than hiding it behind private abstractions unless the file would otherwise become unworkable. - Prefer comprehensive workflow implementations over tiny underscore-prefixed helpers across the repo, especially in application modules. Do not extract routine 2-5 line steps into private functions unless they carry real domain meaning, are reused meaningfully, or materially reduce complexity. - Do not import types from the `typing` module. Python 3.10+ native annotations are the repo standard. diff --git a/src/opai/__init__.py b/src/opai/__init__.py index 4045d96..139a4ee 100644 --- a/src/opai/__init__.py +++ b/src/opai/__init__.py @@ -10,6 +10,7 @@ list_sessions, main, plot_video_frames, + verify_calibrated_parameters, ) __all__ = [ @@ -24,4 +25,5 @@ "list_sessions", "main", "plot_video_frames", + "verify_calibrated_parameters", ] diff --git a/src/opai/application/__init__.py b/src/opai/application/__init__.py index 8e103fa..4348f3e 100644 --- a/src/opai/application/__init__.py +++ b/src/opai/application/__init__.py @@ -1,4 +1,8 @@ -from opai.application.calibration import calibrate, generate_charuco_board +from opai.application.calibration import ( + calibrate, + generate_charuco_board, + verify_calibrated_parameters, +) from opai.application.session import ( add_demos, add_mapping, @@ -13,4 +17,5 @@ "calibrate", "generate_charuco_board", "list_sessions", + "verify_calibrated_parameters", ] diff --git a/src/opai/application/calibration.py b/src/opai/application/calibration.py index e720020..ac096a4 100644 --- a/src/opai/application/calibration.py +++ b/src/opai/application/calibration.py @@ -1,5 +1,6 @@ from __future__ import annotations +import json from collections.abc import Sequence from pathlib import Path @@ -14,6 +15,8 @@ from opai.domain.calibration import ( CalibrationIntrinsics, CalibrationResult, + CalibrationVerificationFrame, + CalibrationVerificationResult, CharucoBoardArtifacts, CharucoBoardConfig, validate_charuco_board_config, @@ -22,6 +25,7 @@ from opai.domain.plot import plot_frames from opai.infrastructure.persistence import ( write_calibration_result, + write_calibration_verification_result, write_charuco_board_config, write_charuco_board_image, ) @@ -195,6 +199,227 @@ def generate_charuco_board( ) +def verify_calibrated_parameters( + ctx: Context, + video_path: str | Path, + n_check_imgs: int, + charuco_config_json: str | Path | dict[str, object], + intrinsics_json: str | Path | dict[str, object], + plot_result: bool = False, + plot_nrows: int | None = None, + plot_ncols: int | None = None, +) -> CalibrationVerificationResult: + if not isinstance(n_check_imgs, int) or isinstance(n_check_imgs, bool): + raise OPAIValidationError( + "n_check_imgs must be an integer greater than 0.", + details={"n_check_imgs": str(n_check_imgs)}, + ) + if n_check_imgs <= 0: + raise OPAIValidationError( + "n_check_imgs must be greater than 0.", + details={"n_check_imgs": n_check_imgs}, + ) + + all_frames = sample_video_frames(video_path=video_path, frame_sample_step=10) + check_image_count = min(n_check_imgs, len(all_frames)) + sampled_frame_indices = tuple( + int(index) + for index in np.linspace( + 0, + len(all_frames) - 1, + num=check_image_count, + dtype=int, + ) + ) + sampled_frames = tuple(all_frames[index] for index in sampled_frame_indices) + + charuco_payload = _load_json_payload( + ctx=ctx, + payload_or_path=charuco_config_json, + payload_name="charuco_config_json", + ) + intrinsics_payload = _load_json_payload( + ctx=ctx, + payload_or_path=intrinsics_json, + payload_name="intrinsics_json", + ) + + charuco_config = _build_charuco_board_config_from_payload(charuco_payload) + validate_charuco_board_config(charuco_config) + camera_matrix, dist_coeffs, intrinsics_image_size = ( + _build_fisheye_parameters_from_payload(intrinsics_payload) + ) + + image_height, image_width = (int(value) for value in sampled_frames[0].shape[:2]) + for frame in sampled_frames[1:]: + if frame.shape[:2] != (image_height, image_width): + raise OPAIWorkflowError( + "Intrinsics verification failed: sampled frames have inconsistent image dimensions." + ) + + if ( + intrinsics_image_size is not None + and ( + image_width, + image_height, + ) + != intrinsics_image_size + ): + raise OPAIValidationError( + "Calibration intrinsics image size does not match the verification video frames.", + details={ + "intrinsics_image_width": intrinsics_image_size[0], + "intrinsics_image_height": intrinsics_image_size[1], + "video_image_width": image_width, + "video_image_height": image_height, + }, + ) + + dictionary_id = getattr(cv2.aruco, charuco_config.dictionary, None) + if dictionary_id is None: + raise OPAIValidationError( + f"Unsupported ArUco dictionary: {charuco_config.dictionary}" + ) + + aruco_dictionary = cv2.aruco.getPredefinedDictionary(dictionary_id) + board = cv2.aruco.CharucoBoard( + (charuco_config.squares_x, charuco_config.squares_y), + charuco_config.square_length, + charuco_config.marker_length, + aruco_dictionary, + ) + charuco_detector = cv2.aruco.CharucoDetector(board) + + board_points = np.asarray(board.getChessboardCorners(), dtype=np.float64) + frame_results: list[CalibrationVerificationFrame] = [] + verification_frames: list[np.ndarray] = [] + total_squared_error_sum = 0.0 + total_corner_count = 0 + + for sampled_frame_index, frame in zip(sampled_frame_indices, sampled_frames): + grayscale = ( + frame if frame.ndim == 2 else cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) + ) + charuco_corners, charuco_ids, _, _ = charuco_detector.detectBoard(grayscale) + if charuco_ids is None or charuco_corners is None: + continue + + observed_ids = np.asarray(charuco_ids, dtype=np.int32).reshape(-1) + if observed_ids.size < 4: + continue + + if np.any(observed_ids < 0) or np.any(observed_ids >= board_points.shape[0]): + raise OPAIWorkflowError( + "Intrinsics verification failed: detected ChArUco ids are outside the configured board range." + ) + + object_points = board_points[observed_ids].reshape(-1, 1, 3) + image_points = np.asarray(charuco_corners, dtype=np.float64).reshape(-1, 1, 2) + + pose_found, rvec, tvec = cv2.solvePnP( + object_points, + image_points, + camera_matrix, + dist_coeffs, + ) + if not pose_found: + continue + + reprojected_points, _ = cv2.fisheye.projectPoints( + object_points, + rvec, + tvec, + camera_matrix, + dist_coeffs, + ) + detected_points = image_points.reshape(-1, 2) + projected_points = reprojected_points.reshape(-1, 2) + deltas = projected_points - detected_points + squared_error_sum = float(np.sum(deltas * deltas)) + corner_count = int(deltas.shape[0]) + total_squared_error_sum += squared_error_sum + total_corner_count += corner_count + verification_frames.append( + _draw_calibration_verification_overlay( + frame=frame, + detected_points=detected_points, + reprojected_points=projected_points, + ) + ) + frame_results.append( + CalibrationVerificationFrame( + sampled_frame_index=int(sampled_frame_index), + detected_corner_count=corner_count, + mse_reproj_error=squared_error_sum / corner_count, + ) + ) + + if not frame_results or total_corner_count == 0: + raise OPAIWorkflowError( + "Intrinsics verification failed: no sampled frames produced a valid ChArUco pose." + ) + + if plot_result: + try: + plot_frames( + verification_frames, + nrows=plot_nrows, + ncols=plot_ncols, + frames_are_bgr=True, + ) + except ValueError as exc: + raise OPAIValidationError(str(exc)) from exc + + result = CalibrationVerificationResult( + requested_check_image_count=int(n_check_imgs), + sampled_image_count=int(check_image_count), + verified_image_count=len(frame_results), + skipped_image_count=int(check_image_count - len(frame_results)), + total_detected_corner_count=total_corner_count, + mse_reproj_error=total_squared_error_sum / total_corner_count, + frame_results=tuple(frame_results), + ) + write_calibration_verification_result(ctx.session_directory, result) + return result + + +def _draw_calibration_verification_overlay( + frame: np.ndarray, + detected_points: np.ndarray, + reprojected_points: np.ndarray, +) -> np.ndarray: + overlay = np.ascontiguousarray(frame.copy()) + if overlay.ndim == 2: + overlay = cv2.cvtColor(overlay, cv2.COLOR_GRAY2BGR) + + for detected_point, reprojected_point in zip(detected_points, reprojected_points): + detected_pixel = tuple(int(round(value)) for value in detected_point) + reprojected_pixel = tuple(int(round(value)) for value in reprojected_point) + cv2.arrowedLine( + overlay, + detected_pixel, + reprojected_pixel, + (0, 255, 255), + 1, + tipLength=0.2, + ) + cv2.circle( + overlay, + detected_pixel, + 5, + (0, 255, 0), + 2, + ) + cv2.circle( + overlay, + reprojected_pixel, + 3, + (0, 0, 255), + -1, + ) + return overlay + + def sample_video_frames( video_path: str | Path, frame_sample_step: int, @@ -228,6 +453,182 @@ def sample_video_frames( return frames +def _load_json_payload( + ctx: Context, + payload_or_path: str | Path | dict[str, object], + payload_name: str, +) -> dict[str, object]: + if isinstance(payload_or_path, dict): + return payload_or_path + + if not isinstance(payload_or_path, (str, Path)): + raise OPAIValidationError( + f"{payload_name} must be a dict payload or a JSON file path.", + details={"payload_name": payload_name}, + ) + + raw_path = Path(payload_or_path).expanduser() + candidate_paths = ( + (raw_path,) + if raw_path.is_absolute() + else (ctx.session_directory / raw_path, raw_path) + ) + + resolved_path: Path | None = None + for candidate_path in candidate_paths: + if candidate_path.exists(): + resolved_path = candidate_path + break + + if resolved_path is None: + raise OPAIValidationError( + f"{payload_name} JSON file was not found.", + details={ + "payload_name": payload_name, + "path": str(raw_path), + }, + ) + + try: + payload = json.loads(resolved_path.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + raise OPAIValidationError( + f"{payload_name} must be valid JSON.", + details={ + "payload_name": payload_name, + "path": str(resolved_path), + }, + ) from exc + if not isinstance(payload, dict): + raise OPAIValidationError( + f"{payload_name} must decode to a JSON object.", + details={ + "payload_name": payload_name, + "path": str(resolved_path), + }, + ) + return payload + + +def _build_charuco_board_config_from_payload( + payload: dict[str, object], +) -> CharucoBoardConfig: + dictionary = payload.get("dictionary") + if not isinstance(dictionary, str): + raise OPAIValidationError( + "charuco_config_json is missing a valid dictionary field.", + details={"field": "dictionary"}, + ) + + try: + return CharucoBoardConfig( + dictionary=dictionary.strip(), + squares_x=int(payload["squares_x"]), + squares_y=int(payload["squares_y"]), + square_length=float(payload["square_length"]), + marker_length=float(payload["marker_length"]), + image_width_px=int(payload["image_width_px"]), + image_height_px=int(payload["image_height_px"]), + margin_size_px=int(payload["margin_size_px"]), + ) + except KeyError as exc: + raise OPAIValidationError( + "charuco_config_json is missing required ChArUco fields.", + details={"field": str(exc)}, + ) from exc + except (TypeError, ValueError) as exc: + raise OPAIValidationError( + "charuco_config_json contains invalid ChArUco field values.", + details={"error": str(exc)}, + ) from exc + + +def _build_fisheye_parameters_from_payload( + payload: dict[str, object], +) -> tuple[np.ndarray, np.ndarray, tuple[int, int] | None]: + intrinsics_payload = payload.get("intrinsics") + if not isinstance(intrinsics_payload, dict): + raise OPAIValidationError( + "intrinsics_json must include an intrinsics object.", + details={"field": "intrinsics"}, + ) + + try: + focal_length = float(intrinsics_payload["focal_length"]) + aspect_ratio = float(intrinsics_payload["aspect_ratio"]) + principal_pt_x = float(intrinsics_payload["principal_pt_x"]) + principal_pt_y = float(intrinsics_payload["principal_pt_y"]) + skew = float(intrinsics_payload["skew"]) + radial_distortion_1 = float(intrinsics_payload["radial_distortion_1"]) + radial_distortion_2 = float(intrinsics_payload["radial_distortion_2"]) + radial_distortion_3 = float(intrinsics_payload["radial_distortion_3"]) + radial_distortion_4 = float(intrinsics_payload["radial_distortion_4"]) + except KeyError as exc: + raise OPAIValidationError( + "intrinsics_json is missing required intrinsic fields.", + details={"field": str(exc)}, + ) from exc + except (TypeError, ValueError) as exc: + raise OPAIValidationError( + "intrinsics_json contains invalid intrinsic field values.", + details={"error": str(exc)}, + ) from exc + + values_to_check = [ + focal_length, + aspect_ratio, + principal_pt_x, + principal_pt_y, + skew, + radial_distortion_1, + radial_distortion_2, + radial_distortion_3, + radial_distortion_4, + ] + if not all(np.isfinite(value) for value in values_to_check): + raise OPAIValidationError("intrinsics_json contains non-finite numeric values.") + if focal_length <= 0.0: + raise OPAIValidationError("intrinsics focal_length must be positive.") + if aspect_ratio <= 0.0: + raise OPAIValidationError("intrinsics aspect_ratio must be positive.") + + focal_length_y = focal_length / aspect_ratio + camera_matrix = np.array( + [ + [focal_length, skew, principal_pt_x], + [0.0, focal_length_y, principal_pt_y], + [0.0, 0.0, 1.0], + ], + dtype=np.float64, + ) + dist_coeffs = np.array( + [ + [radial_distortion_1], + [radial_distortion_2], + [radial_distortion_3], + [radial_distortion_4], + ], + dtype=np.float64, + ) + + if "image_width" not in payload or "image_height" not in payload: + return camera_matrix, dist_coeffs, None + + try: + image_width = int(payload["image_width"]) + image_height = int(payload["image_height"]) + except (TypeError, ValueError) as exc: + raise OPAIValidationError( + "intrinsics_json image_width and image_height must be integers.", + details={"error": str(exc)}, + ) from exc + if image_width <= 0 or image_height <= 0: + raise OPAIValidationError( + "intrinsics_json image_width and image_height must be positive." + ) + return camera_matrix, dist_coeffs, (image_width, image_height) + + def _validate_inputs( frames: Sequence[np.ndarray], row_count: int, diff --git a/src/opai/domain/__init__.py b/src/opai/domain/__init__.py index 84a8667..1acb649 100644 --- a/src/opai/domain/__init__.py +++ b/src/opai/domain/__init__.py @@ -1,5 +1,7 @@ from opai.domain.calibration import ( CalibrationResult, + CalibrationVerificationFrame, + CalibrationVerificationResult, CharucoBoardArtifacts, CharucoBoardConfig, ) @@ -8,6 +10,8 @@ __all__ = [ "CalibrationResult", + "CalibrationVerificationFrame", + "CalibrationVerificationResult", "CharucoBoardArtifacts", "CharucoBoardConfig", "Context", diff --git a/src/opai/domain/calibration.py b/src/opai/domain/calibration.py index 6b2030a..b8679c1 100644 --- a/src/opai/domain/calibration.py +++ b/src/opai/domain/calibration.py @@ -41,6 +41,24 @@ class CalibrationResult: dist_coeffs: np.ndarray +@dataclass(frozen=True) +class CalibrationVerificationFrame: + sampled_frame_index: int + detected_corner_count: int + mse_reproj_error: float + + +@dataclass(frozen=True) +class CalibrationVerificationResult: + requested_check_image_count: int + sampled_image_count: int + verified_image_count: int + skipped_image_count: int + total_detected_corner_count: int + mse_reproj_error: float + frame_results: tuple[CalibrationVerificationFrame, ...] + + @dataclass(frozen=True) class CharucoBoardConfig: dictionary: str diff --git a/src/opai/domain/plot.py b/src/opai/domain/plot.py index 9da3378..2fa2d3f 100644 --- a/src/opai/domain/plot.py +++ b/src/opai/domain/plot.py @@ -85,7 +85,7 @@ def plot_frames( def _get_figsize( grid: PlotGrid, *, - imsize: float = 3.0, + imsize: float = 5.0, add_vert: float = 0.6, ) -> tuple[float, float]: return grid.ncols * imsize, grid.nrows * imsize + add_vert diff --git a/src/opai/infrastructure/__init__.py b/src/opai/infrastructure/__init__.py index eac5697..f7e1390 100644 --- a/src/opai/infrastructure/__init__.py +++ b/src/opai/infrastructure/__init__.py @@ -7,10 +7,12 @@ from opai.infrastructure.persistence import ( load_session_manifest, write_calibration_result, + write_calibration_verification_result, write_charuco_board_config, write_charuco_board_image, write_session_manifest, ) +from opai.infrastructure.video import sample_video_frames __all__ = [ "get_active_context", @@ -19,7 +21,9 @@ "list_session_names", "load_session_manifest", "write_calibration_result", + "write_calibration_verification_result", "write_charuco_board_config", "write_charuco_board_image", "write_session_manifest", + "sample_video_frames", ] diff --git a/src/opai/infrastructure/persistence.py b/src/opai/infrastructure/persistence.py index 2ac6abb..a9ba64e 100644 --- a/src/opai/infrastructure/persistence.py +++ b/src/opai/infrastructure/persistence.py @@ -9,7 +9,11 @@ import numpy as np from opai.core.exceptions import OPAIWorkflowError -from opai.domain.calibration import CalibrationResult, CharucoBoardConfig +from opai.domain.calibration import ( + CalibrationResult, + CalibrationVerificationResult, + CharucoBoardConfig, +) from opai.domain.session import DemoAsset, MappingAsset, SessionManifest @@ -38,6 +42,35 @@ def write_calibration_result( output_path = session_directory / filename output_path.write_text(json.dumps(payload, indent=2), encoding="utf-8") + print(f"Wrote calibration verification result to {output_path}") + return output_path + + +def write_calibration_verification_result( + session_directory: Path, + result: CalibrationVerificationResult, + filename: str = "calibration_verification.json", +) -> Path: + payload = { + "requested_check_image_count": result.requested_check_image_count, + "sampled_image_count": result.sampled_image_count, + "verified_image_count": result.verified_image_count, + "skipped_image_count": result.skipped_image_count, + "total_detected_corner_count": result.total_detected_corner_count, + "mse_reproj_error": result.mse_reproj_error, + "frame_results": [ + { + "sampled_frame_index": frame.sampled_frame_index, + "detected_corner_count": frame.detected_corner_count, + "mse_reproj_error": frame.mse_reproj_error, + } + for frame in result.frame_results + ], + } + + output_path = session_directory / filename + output_path.write_text(json.dumps(payload, indent=2), encoding="utf-8") + print(f"Wrote calibration verification result to {output_path}") return output_path diff --git a/src/opai/presentation/__init__.py b/src/opai/presentation/__init__.py index 4045d96..139a4ee 100644 --- a/src/opai/presentation/__init__.py +++ b/src/opai/presentation/__init__.py @@ -10,6 +10,7 @@ list_sessions, main, plot_video_frames, + verify_calibrated_parameters, ) __all__ = [ @@ -24,4 +25,5 @@ "list_sessions", "main", "plot_video_frames", + "verify_calibrated_parameters", ] diff --git a/src/opai/presentation/facade.py b/src/opai/presentation/facade.py index 3ee7d57..abdf8d4 100644 --- a/src/opai/presentation/facade.py +++ b/src/opai/presentation/facade.py @@ -21,6 +21,7 @@ DEFAULT_CHARUCO_SQUARES_X, DEFAULT_CHARUCO_SQUARES_Y, CalibrationResult, + CalibrationVerificationResult, CharucoBoardArtifacts, CharucoBoardConfig, ) @@ -165,6 +166,32 @@ def calibrate_with_video( ) +def verify_calibrated_parameters( + video_path: str | Path, + n_check_imgs: int, + charuco_config_json: str | Path | dict[str, object], + intrinsics_json: str | Path | dict[str, object], + plot_result: bool = False, + plot_nrows: int | None = None, + plot_ncols: int | None = None, +) -> CalibrationVerificationResult: + ctx = get_context() + from opai.application.calibration import ( + verify_calibrated_parameters as verify_with_context, + ) + + return verify_with_context( + ctx=ctx, + video_path=video_path, + n_check_imgs=n_check_imgs, + charuco_config_json=charuco_config_json, + intrinsics_json=intrinsics_json, + plot_result=plot_result, + plot_nrows=plot_nrows, + plot_ncols=plot_ncols, + ) + + def plot_video_frames( video_path: str | Path, frame_sample_step: int, @@ -307,5 +334,6 @@ def main() -> None: print( "Use opai.init(name), opai.add_demos(...), opai.add_mapping(...), " "opai.generate_charuco_board(...), opai.calibrate(...), " - "opai.calibrate_with_video(...), and opai.plot_video_frames(...) from Python." + "opai.calibrate_with_video(...), opai.verify_calibrated_parameters(...), " + "and opai.plot_video_frames(...) from Python." ) diff --git a/tests/test_calibration.py b/tests/test_calibration.py index 69cea51..df615f0 100644 --- a/tests/test_calibration.py +++ b/tests/test_calibration.py @@ -1,5 +1,6 @@ from __future__ import annotations +import json from types import SimpleNamespace import numpy as np @@ -12,6 +13,7 @@ _compute_mse_reprojection_error, calibrate, sample_video_frames, + verify_calibrated_parameters, ) from opai.core.exceptions import OPAIValidationError, OPAIWorkflowError from opai.domain.context import Context @@ -236,6 +238,204 @@ def test_sample_video_frames_rejects_empty_sampling( sample_video_frames(video_path, 2) +def test_verify_calibrated_parameters_writes_verification_artifact( + tmp_path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + ctx = Context(name="session", session_directory=tmp_path) + frames = tuple( + np.full((10, 12, 3), fill_value=index, dtype=np.uint8) for index in range(4) + ) + monkeypatch.setattr( + calibration_module, + "sample_video_frames", + lambda **_kwargs: frames, + ) + monkeypatch.setattr(calibration_module, "cv2", _build_verify_cv2()) + plot_calls: list[tuple[tuple[np.ndarray, ...], dict[str, object]]] = [] + + def fake_plot_frames( + plotted_frames: tuple[np.ndarray, ...] | list[np.ndarray], + **kwargs, + ) -> None: + plot_calls.append((tuple(plotted_frames), kwargs)) + + monkeypatch.setattr(calibration_module, "plot_frames", fake_plot_frames) + + result = verify_calibrated_parameters( + ctx=ctx, + video_path=tmp_path / "demo.mp4", + n_check_imgs=2, + charuco_config_json={ + "dictionary": "DICT_4X4_50", + "squares_x": 3, + "squares_y": 3, + "square_length": 1.0, + "marker_length": 0.5, + "image_width_px": 1200, + "image_height_px": 800, + "margin_size_px": 20, + }, + intrinsics_json={ + "image_width": 12, + "image_height": 10, + "intrinsics": { + "focal_length": 10.0, + "aspect_ratio": 0.5, + "principal_pt_x": 5.0, + "principal_pt_y": 6.0, + "radial_distortion_1": 0.1, + "radial_distortion_2": 0.2, + "radial_distortion_3": 0.3, + "radial_distortion_4": 0.4, + "skew": 0.0, + }, + }, + plot_result=True, + plot_nrows=1, + plot_ncols=2, + ) + + assert result.requested_check_image_count == 2 + assert result.sampled_image_count == 2 + assert result.verified_image_count == 2 + assert result.skipped_image_count == 0 + assert result.total_detected_corner_count == 8 + assert result.mse_reproj_error == pytest.approx(5.0) + assert [frame.sampled_frame_index for frame in result.frame_results] == [0, 3] + assert len(plot_calls) == 1 + assert len(plot_calls[0][0]) == 2 + assert plot_calls[0][1] == {"nrows": 1, "ncols": 2, "frames_are_bgr": True} + plotted_frame = plot_calls[0][0][0] + assert np.any( + np.all(plotted_frame == np.array([0, 255, 0], dtype=np.uint8), axis=-1) + ) + assert np.any( + np.all(plotted_frame == np.array([0, 0, 255], dtype=np.uint8), axis=-1) + ) + + verification_path = tmp_path / "calibration_verification.json" + payload = json.loads(verification_path.read_text(encoding="utf-8")) + assert payload["verified_image_count"] == 2 + assert payload["mse_reproj_error"] == pytest.approx(5.0) + + +def test_verify_calibrated_parameters_skips_plotting_by_default( + tmp_path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + ctx = Context(name="session", session_directory=tmp_path) + frames = tuple( + np.full((10, 12, 3), fill_value=index, dtype=np.uint8) for index in range(3) + ) + monkeypatch.setattr( + calibration_module, + "sample_video_frames", + lambda **_kwargs: frames, + ) + monkeypatch.setattr(calibration_module, "cv2", _build_verify_cv2()) + monkeypatch.setattr( + calibration_module, + "plot_frames", + lambda *_args, **_kwargs: pytest.fail( + "plot_frames should not run when plot_result=False" + ), + ) + + result = verify_calibrated_parameters( + ctx=ctx, + video_path=tmp_path / "demo.mp4", + n_check_imgs=2, + charuco_config_json={ + "dictionary": "DICT_4X4_50", + "squares_x": 3, + "squares_y": 3, + "square_length": 1.0, + "marker_length": 0.5, + "image_width_px": 1200, + "image_height_px": 800, + "margin_size_px": 20, + }, + intrinsics_json={ + "image_width": 12, + "image_height": 10, + "intrinsics": { + "focal_length": 10.0, + "aspect_ratio": 0.5, + "principal_pt_x": 5.0, + "principal_pt_y": 6.0, + "radial_distortion_1": 0.1, + "radial_distortion_2": 0.2, + "radial_distortion_3": 0.3, + "radial_distortion_4": 0.4, + "skew": 0.0, + }, + }, + ) + + assert result.verified_image_count == 2 + + +def test_verify_calibrated_parameters_rejects_non_positive_check_count( + tmp_path, +) -> None: + ctx = Context(name="session", session_directory=tmp_path) + + with pytest.raises(OPAIValidationError, match="n_check_imgs"): + verify_calibrated_parameters( + ctx=ctx, + video_path=tmp_path / "demo.mp4", + n_check_imgs=0, + charuco_config_json={}, + intrinsics_json={}, + ) + + +def test_verify_calibrated_parameters_rejects_intrinsics_image_size_mismatch( + tmp_path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + ctx = Context(name="session", session_directory=tmp_path) + monkeypatch.setattr( + calibration_module, + "sample_video_frames", + lambda **_kwargs: (np.zeros((10, 12, 3), dtype=np.uint8),), + ) + monkeypatch.setattr(calibration_module, "cv2", _build_verify_cv2()) + + with pytest.raises(OPAIValidationError, match="image size does not match"): + verify_calibrated_parameters( + ctx=ctx, + video_path=tmp_path / "demo.mp4", + n_check_imgs=1, + charuco_config_json={ + "dictionary": "DICT_4X4_50", + "squares_x": 3, + "squares_y": 3, + "square_length": 1.0, + "marker_length": 0.5, + "image_width_px": 1200, + "image_height_px": 800, + "margin_size_px": 20, + }, + intrinsics_json={ + "image_width": 11, + "image_height": 10, + "intrinsics": { + "focal_length": 10.0, + "aspect_ratio": 0.5, + "principal_pt_x": 5.0, + "principal_pt_y": 6.0, + "radial_distortion_1": 0.1, + "radial_distortion_2": 0.2, + "radial_distortion_3": 0.3, + "radial_distortion_4": 0.4, + "skew": 0.0, + }, + }, + ) + + def test_infrastructure_video_sampling_starts_at_zero_and_respects_step( monkeypatch: pytest.MonkeyPatch, tmp_path, @@ -290,3 +490,100 @@ def test_repo_exceptions_expose_error_codes_and_payload() -> None: ) assert overridden.error_code == "charuco_calibration_failed" + + +def _build_verify_cv2() -> SimpleNamespace: + def fake_cvt_color(frame: np.ndarray, code: int) -> np.ndarray: + if code == 1: + return frame[:, :, 0] + if code == 2: + return np.repeat(frame[:, :, None], 3, axis=2) + raise AssertionError(f"Unexpected color conversion code: {code}") + + def fake_circle( + image: np.ndarray, + center: tuple[int, int], + _radius: int, + color: tuple[int, int, int], + _thickness: int, + ) -> np.ndarray: + x, y = center + if 0 <= y < image.shape[0] and 0 <= x < image.shape[1]: + image[y, x] = np.array(color, dtype=image.dtype) + return image + + def fake_arrowed_line( + image: np.ndarray, + start: tuple[int, int], + end: tuple[int, int], + color: tuple[int, int, int], + _thickness: int, + *, + tipLength: float = 0.0, + ) -> np.ndarray: + del tipLength + midpoint = ( + int(round((start[0] + end[0]) / 2)), + int(round((start[1] + end[1]) / 2)), + ) + for x, y in (start, midpoint, end): + if 0 <= y < image.shape[0] and 0 <= x < image.shape[1]: + image[y, x] = np.array(color, dtype=image.dtype) + return image + + board_points = np.array( + [ + [0.0, 0.0, 0.0], + [1.0, 0.0, 0.0], + [0.0, 1.0, 0.0], + [1.0, 1.0, 0.0], + ], + dtype=np.float64, + ) + observed_corners = np.array( + [ + [[1.0, 1.0]], + [[2.0, 1.0]], + [[1.0, 2.0]], + [[2.0, 2.0]], + ], + dtype=np.float64, + ) + observed_ids = np.array([[0], [1], [2], [3]], dtype=np.int32) + + class FakeCharucoDetector: + def __init__(self, _board) -> None: + self.board = _board + + def detectBoard(self, _image): + return observed_corners, observed_ids, None, None + + aruco = SimpleNamespace( + DICT_4X4_50=1, + getPredefinedDictionary=lambda _dictionary_id: "dictionary", + CharucoBoard=lambda *_args, **_kwargs: SimpleNamespace( + getChessboardCorners=lambda: board_points + ), + CharucoDetector=FakeCharucoDetector, + ) + fisheye = SimpleNamespace( + projectPoints=lambda _object_points, *_args: ( + observed_corners + np.array([[[1.0, 2.0]]], dtype=np.float64), + None, + ) + ) + + return SimpleNamespace( + aruco=aruco, + fisheye=fisheye, + COLOR_BGR2GRAY=1, + COLOR_GRAY2BGR=2, + cvtColor=fake_cvt_color, + arrowedLine=fake_arrowed_line, + circle=fake_circle, + solvePnP=lambda *_args, **_kwargs: ( + True, + np.zeros((3, 1), dtype=np.float64), + np.zeros((3, 1), dtype=np.float64), + ), + ) diff --git a/tests/test_facade.py b/tests/test_facade.py index 02f8798..3b770ef 100644 --- a/tests/test_facade.py +++ b/tests/test_facade.py @@ -33,6 +33,21 @@ def test_calibrate_with_video_requires_context(tmp_path, monkeypatch) -> None: opai.calibrate_with_video(video_path, 2, 3, 3, 1.0, 0.5, "DICT_4X4_50") +def test_verify_calibrated_parameters_requires_context(tmp_path, monkeypatch) -> None: + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(context_store, "_ACTIVE_CONTEXT", None) + video_path = tmp_path / "demo.mp4" + video_path.write_bytes(b"demo") + + with pytest.raises(OPAIContextError, match="Call opai.init"): + opai.verify_calibrated_parameters( + video_path=video_path, + n_check_imgs=2, + charuco_config_json={}, + intrinsics_json={}, + ) + + def test_init_creates_context_directory(tmp_path, monkeypatch) -> None: monkeypatch.chdir(tmp_path) ctx = opai.init("session-001") @@ -237,6 +252,100 @@ def test_calibrate_with_video_plots_detected_corners_when_enabled( assert np.array_equal(pyplot.axes[0].images[0], frames[0][..., ::-1]) +def test_verify_calibrated_parameters_uses_session_relative_json_paths( + tmp_path, + monkeypatch, +) -> None: + fake_cv2 = _build_fake_cv2() + monkeypatch.setattr(calibration_module, "cv2", fake_cv2) + monkeypatch.chdir(tmp_path) + ctx = opai.init("session-001") + + frames = tuple( + np.full((10, 12, 3), fill_value=index, dtype=np.uint8) for index in range(3) + ) + video_path = tmp_path / "demo.mp4" + video_path.write_bytes(b"demo") + monkeypatch.setattr( + calibration_module, + "sample_video_frames", + lambda **_kwargs: frames, + ) + plot_calls: list[tuple[tuple[np.ndarray, ...], dict[str, object]]] = [] + + def fake_plot_frames( + plotted_frames: tuple[np.ndarray, ...] | list[np.ndarray], + **kwargs, + ) -> None: + plot_calls.append((tuple(plotted_frames), kwargs)) + + monkeypatch.setattr(calibration_module, "plot_frames", fake_plot_frames) + + charuco_path = ctx.session_directory / "charuco_config.json" + charuco_path.write_text( + json.dumps( + { + "dictionary": "DICT_4X4_50", + "squares_x": 3, + "squares_y": 3, + "square_length": 1.0, + "marker_length": 0.5, + "image_width_px": 1200, + "image_height_px": 800, + "margin_size_px": 20, + "board_image_path": "charuco_board.png", + } + ), + encoding="utf-8", + ) + intrinsics_path = ctx.session_directory / "calibration.json" + intrinsics_path.write_text( + json.dumps( + { + "mse_reproj_error": 0.1, + "image_height": 10, + "image_width": 12, + "intrinsic_type": "FISHEYE", + "intrinsics": { + "aspect_ratio": 0.5, + "focal_length": 10.0, + "principal_pt_x": 5.0, + "principal_pt_y": 6.0, + "radial_distortion_1": 0.1, + "radial_distortion_2": 0.2, + "radial_distortion_3": 0.3, + "radial_distortion_4": 0.4, + "skew": 0.0, + }, + } + ), + encoding="utf-8", + ) + + result = opai.verify_calibrated_parameters( + video_path=video_path, + n_check_imgs=2, + charuco_config_json="charuco_config.json", + intrinsics_json="calibration.json", + plot_result=True, + plot_nrows=1, + plot_ncols=2, + ) + + assert result.sampled_image_count == 2 + assert result.verified_image_count == 2 + assert result.total_detected_corner_count == 8 + assert len(plot_calls) == 1 + assert len(plot_calls[0][0]) == 2 + assert plot_calls[0][1] == {"nrows": 1, "ncols": 2, "frames_are_bgr": True} + output_path = ( + tmp_path / ".opai_sessions" / "session-001" / "calibration_verification.json" + ) + assert output_path.exists() + payload = json.loads(output_path.read_text(encoding="utf-8")) + assert payload["verified_image_count"] == 2 + + def test_calibrate_with_video_requires_matplotlib_for_detected_corner_plotting( tmp_path, monkeypatch, @@ -545,6 +654,37 @@ def fake_import(name, globals=None, locals=None, fromlist=(), level=0): def _build_fake_cv2() -> SimpleNamespace: + def fake_circle( + image: np.ndarray, + center: tuple[int, int], + _radius: int, + color: tuple[int, int, int], + _thickness: int, + ) -> np.ndarray: + x, y = center + if 0 <= y < image.shape[0] and 0 <= x < image.shape[1]: + image[y, x] = np.array(color, dtype=image.dtype) + return image + + def fake_arrowed_line( + image: np.ndarray, + start: tuple[int, int], + end: tuple[int, int], + color: tuple[int, int, int], + _thickness: int, + *, + tipLength: float = 0.0, + ) -> np.ndarray: + del tipLength + midpoint = ( + int(round((start[0] + end[0]) / 2)), + int(round((start[1] + end[1]) / 2)), + ) + for x, y in (start, midpoint, end): + if 0 <= y < image.shape[0] and 0 <= x < image.shape[1]: + image[y, x] = np.array(color, dtype=image.dtype) + return image + def fake_fisheye_calibrate( object_points, image_points, @@ -630,6 +770,13 @@ def fake_cvt_color(frame: np.ndarray, code: int) -> np.ndarray: COLOR_BGR2GRAY=1, COLOR_GRAY2BGR=2, cvtColor=fake_cvt_color, + arrowedLine=fake_arrowed_line, + circle=fake_circle, + solvePnP=lambda *_args, **_kwargs: ( + True, + np.zeros((3, 1), dtype=np.float64), + np.zeros((3, 1), dtype=np.float64), + ), waitKey=lambda _delay: 0, ) @@ -653,7 +800,6 @@ def add(self, label: str): class FakeConsole: def print(self, *_args, **_kwargs) -> None: recorder["prints"].append(_args) - return None monkeypatch.setitem(sys.modules, "rich", SimpleNamespace()) monkeypatch.setitem( From f371c0a14be68f4195bbb189bfe472be22391f86 Mon Sep 17 00:00:00 2001 From: author31 Date: Mon, 23 Mar 2026 11:33:01 +0800 Subject: [PATCH 8/8] add doc --- docs/calibration-workflow.md | 237 +++++++++++++++++++++++++++++++++++ src/opai/domain/plot.py | 28 +++-- 2 files changed, 257 insertions(+), 8 deletions(-) create mode 100644 docs/calibration-workflow.md diff --git a/docs/calibration-workflow.md b/docs/calibration-workflow.md new file mode 100644 index 0000000..f9bcb8c --- /dev/null +++ b/docs/calibration-workflow.md @@ -0,0 +1,237 @@ +# Calibration Workflow With `opai` + +This guide documents the notebook-facing camera calibration workflow in `opai`. +The intended flow is: + +1. Start an `opai` session. +2. Generate a ChArUco board and print it. +3. Record a calibration video outside `opai`. +4. Run calibration from the recorded video. +5. Optionally verify the saved intrinsics. + +The notebook API lives at the package root, so the standard workflow should use `opai.*` directly. + +## What `opai` Provides + +`opai` currently supports these notebook-facing calibration calls: + +```python +ctx = opai.init(name) +board = opai.generate_charuco_board(...) +opai.plot_video_frames(video_path, frame_sample_step=...) +result = opai.calibrate_with_video(...) +verification = opai.verify_calibrated_parameters(...) +``` + +`opai` does not currently record camera video for you. The video capture step must be done with your normal camera app, CLI tool, or another script, then passed back into `opai` by file path. + +## 1. Start A Calibration Session + +Every notebook workflow starts by creating the active context: + +```python +import opai + +ctx = opai.init("camera-calibration") +ctx.session_directory +``` + +This creates or resumes the session directory at: + +```text +.opai_sessions/camera-calibration/ +``` + +All calibration artifacts are written under that directory. If you call `opai.init(...)` again with the same name, `opai` resumes that existing session directory and reuses its manifest. + +## 2. Generate A ChArUco Board + +Generate the board from the notebook and keep the returned config object. That avoids retyping parameters later. + +```python +board = opai.generate_charuco_board( + dictionary="DICT_5X5_100", + squares_x=11, + squares_y=8, + square_length=0.03, + marker_length=0.022, + image_width_px=2000, + image_height_px=1400, + margin_size_px=20, +) + +board.image_path +board.config_path +board.config +``` + +This writes: + +- `charuco_board.png` +- `charuco_config.json` + +Both files are saved into the active session directory. + +### Parameter Notes + +- `dictionary` must be a valid OpenCV ArUco dictionary name such as `DICT_5X5_100`. +- `square_length` and `marker_length` must be positive. +- `marker_length` must be smaller than `square_length`. +- `square_length` and `marker_length` should use a real-world unit consistently. Meters are a reasonable default. + +### Printing Guidance + +- Print the generated board without rescaling it after export. +- Use a flat, rigid backing if possible. +- Measure the printed square size if print scaling is a concern. +- Use the same board for the full calibration run. + +## 3. Record The Calibration Video + +Record the calibration video outside `opai`, then save it somewhere accessible from the notebook, for example: + +```python +video_path = "/path/to/calibration.mp4" +``` + +Recommended capture guidance: + +- Use the same camera, lens, focus mode, and image resolution you plan to use later. +- Move the board through the center, edges, and corners of the image. +- Capture different distances and tilt angles. +- Avoid heavy motion blur and severe occlusion. +- Keep enough frames where the board is fully visible and sharp. + +`opai.calibrate_with_video(...)` samples frames from the saved video, so one continuous handheld calibration clip is enough. + +## 4. Preview Sampled Frames + +Before calibrating, it can help to inspect the frames that `opai` will sample: + +```python +opai.plot_video_frames( + video_path=video_path, + frame_sample_step=15, +) +``` + +Use this to check whether the chosen sampling step keeps enough diverse board poses. `frame_sample_step` must be greater than `0`. + +## 5. Run Calibration From The Video + +The safest pattern is to reuse the values from `board.config` so the calibration inputs stay consistent with the generated board: + +```python +result = opai.calibrate_with_video( + video_path=video_path, + frame_sample_step=15, + row_count=board.config.squares_y, + col_count=board.config.squares_x, + square_length=board.config.square_length, + marker_length=board.config.marker_length, + dictionary=board.config.dictionary, + plot_result=True, +) + +result +``` + +Important mapping: + +- `row_count` corresponds to `squares_y` +- `col_count` corresponds to `squares_x` + +This workflow: + +- samples frames from the video +- detects ChArUco corners +- calibrates a fisheye camera model +- writes `calibration.json` into the session directory + +If `plot_result=True`, `opai` also plots the detected ChArUco corners on the frames that were kept for calibration. + +## 6. Optional Verification + +After calibration, you can verify the saved intrinsics against a calibration video: + +```python +verification = opai.verify_calibrated_parameters( + video_path=video_path, + n_check_imgs=10, + charuco_config_json="charuco_config.json", + intrinsics_json="calibration.json", + plot_result=True, +) + +verification +``` + +For verification, relative JSON paths are resolved against the active session directory first. This makes the saved session artifacts convenient to reuse directly from notebook cells. + +Verification writes: + +- `calibration_verification.json` + +## Session Artifacts + +After a typical calibration workflow, the session directory will contain files like: + +```text +.opai_sessions/camera-calibration/ +├── session.json +├── charuco_board.png +├── charuco_config.json +├── calibration.json +└── calibration_verification.json +``` + +`calibration_verification.json` is only created if you run verification. + +## Complete Notebook Example + +```python +import opai + +ctx = opai.init("camera-calibration") + +board = opai.generate_charuco_board( + dictionary="DICT_5X5_100", + squares_x=11, + squares_y=8, + square_length=0.03, + marker_length=0.022, +) + +video_path = "/path/to/calibration.mp4" + +opai.plot_video_frames(video_path=video_path, frame_sample_step=15) + +result = opai.calibrate_with_video( + video_path=video_path, + frame_sample_step=15, + row_count=board.config.squares_y, + col_count=board.config.squares_x, + square_length=board.config.square_length, + marker_length=board.config.marker_length, + dictionary=board.config.dictionary, + plot_result=True, +) + +verification = opai.verify_calibrated_parameters( + video_path=video_path, + n_check_imgs=10, + charuco_config_json="charuco_config.json", + intrinsics_json="calibration.json", + plot_result=True, +) +``` + +## Common Failure Cases + +- Calling calibration functions before `opai.init(...)`. +- Passing board dimensions that do not match the generated board. +- Using a different dictionary, `square_length`, or `marker_length` than the printed board. +- Sampling a video that contains too few clear ChArUco detections. +- Using verification video frames whose image size does not match the saved intrinsics. + +If you already have frames in memory instead of a video file, you can call `opai.calibrate(...)` directly with a sequence of `numpy.ndarray` frames, but the board parameters still need to match the printed ChArUco board. diff --git a/src/opai/domain/plot.py b/src/opai/domain/plot.py index 2fa2d3f..fa8d992 100644 --- a/src/opai/domain/plot.py +++ b/src/opai/domain/plot.py @@ -6,7 +6,7 @@ import numpy as np -BASE_SUBPLOT_WIDTH = 8.0 +BASE_SUBPLOT_WIDTH = 4.0 BASE_SUBPLOT_HEIGHT = 3.0 MAX_FIGURE_WIDTH = 16.0 MAX_FIGURE_HEIGHT = 12.0 @@ -69,9 +69,10 @@ def plot_frames( flat_axes = np.atleast_1d(axes).reshape(-1) for axis, frame in zip(flat_axes, frames): - if frame.ndim != 2 and frames_are_bgr: - frame = frame[..., ::-1] - axis.imshow(frame) + image = _prepare_frame(frame, grid) + if image.ndim != 2 and frames_are_bgr: + image = image[..., ::-1] + axis.imshow(image) axis.set_axis_off() for axis in flat_axes[len(frames) :]: @@ -84,8 +85,19 @@ def plot_frames( def _get_figsize( grid: PlotGrid, - *, - imsize: float = 5.0, - add_vert: float = 0.6, ) -> tuple[float, float]: - return grid.ncols * imsize, grid.nrows * imsize + add_vert + width = BASE_SUBPLOT_WIDTH * grid.ncols + height = BASE_SUBPLOT_HEIGHT * grid.nrows + scale = min(1.0, MAX_FIGURE_WIDTH / width, MAX_FIGURE_HEIGHT / height) + resolved_height = height * scale + if grid.nrows == 1: + resolved_height = max(resolved_height, MIN_SINGLE_ROW_FIGURE_HEIGHT) + return width * scale, min(resolved_height, MAX_FIGURE_HEIGHT) + + +def _prepare_frame(frame: np.ndarray, grid: PlotGrid) -> np.ndarray: + max_height = max(1, MAX_CANVAS_HEIGHT // grid.nrows) + max_width = max(1, MAX_CANVAS_WIDTH // grid.ncols) + height, width = frame.shape[:2] + stride = max(1, math.ceil(height / max_height), math.ceil(width / max_width)) + return frame[::stride, ::stride]