diff --git a/README.md b/README.md new file mode 100644 index 0000000000000000000000000000000000000000..700f21bfe4f85040508bdce6df36c6c49c2186c1 --- /dev/null +++ b/README.md @@ -0,0 +1,51 @@ +# Splasher + +Outil de **labélisation** dont le cœur est générique : on lui donne un *dataset +synchrone* — à chaque instant, un **pack de canaux nommés** (nuage de points 3D, +image caméra, pose, …) — et on labélise soit une **grille 2D vue-de-dessus (BEV)**, +soit directement les **points 3D**, soit les deux. + +Premier cas d'usage : la **traversabilité**. Mais rien n'est câblé en dur : pas de +schéma de classes imposé, pas de sémantique monde imposée, et **aucune dépendance +obligatoire à un format de dataset**. apairo n'est qu'un adaptateur d'entrée optionnel. + +## Idée + +- Plusieurs canaux synchronisés, affichés comme références : on se balade librement + dans le nuage 3D, on regarde les images caméra. +- On choisit les **canaux** à afficher (dock *Canaux* : montrer/masquer chaque nuage + ou caméra disponible dans la source — plusieurs caméras et nuages possibles). +- On **dessine la grille de carrés** (vue de dessus) : son étendue et la taille de + chaque carré, créée explicitement via **« Nouvelle grille »**. L'annulation (undo) + est **par frame**. +- On **sélectionne un rectangle** à la souris sur cette vue de dessus. Selon la cible : + - **Grid** : remplit les carrés couverts de la classe active (sortie = raster d'IDs). + - **Points** : assigne la classe aux points 3D dans le rectangle (sortie = labels par point). +- Mode **Sélection** (façon bureau) : tracer un rectangle sélectionne des cellules + (**Shift** = ajouter, sélections non contiguës possibles), puis on **applique** la classe + à toute la sélection d'un coup. Changer de grille demande **confirmation** si une + labélisation existe déjà. +- **Cumul** : on peut cumuler ±N frames **recalées par leurs poses** dans le repère du + frame courant (nuage plus dense pour mieux labéliser). La grille et les labels restent + **par frame** : un coup de pinceau sur le nuage cumulé est **décumulé** vers chaque + frame source. (Nécessite un canal `POSE`.) + +## Installation + +```bash +cd ~/dev/splasher +uv sync # cœur seul (numpy + Qt) +uv sync --extra apairo # + adaptateur apairo (optionnel) +``` + +## Démo (zéro donnée externe) + +```bash +uv run python examples/demo_arraysource.py +``` + +## Entrée + +Le cœur consomme une `Source` : `__len__`, `__getitem__(i) -> Frame`, `channels()`. +`ArraySource` en construit une depuis des tableaux numpy en mémoire. `ApairoSource` +(extra `apairo`) enveloppe tout dataset apairo synchrone. diff --git a/examples/demo_arraysource.py b/examples/demo_arraysource.py new file mode 100644 index 0000000000000000000000000000000000000000..a95b85ff79237ba3c30d8d2d40a86f3640b43c35 --- /dev/null +++ b/examples/demo_arraysource.py @@ -0,0 +1,10 @@ +"""Lance Splasher sur une source synthétique — aucune donnée externe requise. + + uv run python examples/demo_arraysource.py +""" + +from splasher import launch +from splasher.demo import make_demo_source + +if __name__ == "__main__": + launch(make_demo_source(), title="Splasher — démo") diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000000000000000000000000000000000000..a4452bbf25f8a10b25728fe02af1cc21e9224f1d --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,36 @@ +[project] +name = "splasher" +version = "0.1.0" +description = "Labélisation : canaux synchronisés (nuages 3D, images) -> grille 2D BEV ou labels par point" +readme = "README.md" +authors = [{ name = "Augustin Bresset", email = "augustin.bresset@gmail.com" }] +requires-python = ">=3.11" +dependencies = [ + "numpy", + "pyqtgraph", + "PySide6", + "PyOpenGL", +] + +# apairo n'est qu'UN adaptateur d'entrée optionnel — le cœur n'en dépend pas. +[project.optional-dependencies] +apairo = [ + "apairo @ git+https://github.com/apairo-robotics/apairo.git", + "PyYAML", +] + +[project.scripts] +splasher = "splasher.cli:main" + +[dependency-groups] +dev = ["pytest"] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["splasher"] + +[tool.hatch.metadata] +allow-direct-references = true diff --git a/splasher/__init__.py b/splasher/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..93fde149892cf9684a0c14c6cce38d106a0be817 --- /dev/null +++ b/splasher/__init__.py @@ -0,0 +1,33 @@ +"""Splasher — labélisation de canaux synchronisés vers une grille 2D BEV ou des labels par point. + +Le cœur (`splasher.core`) ne dépend que de numpy et reste importable sans toolkit UI. +L'UI (PySide6/pyqtgraph) n'est chargée qu'au moment de `launch()`. +""" + +from __future__ import annotations + +from .core.source import ChannelKind, ChannelSpec, Frame, Source, channels_of_kind +from .core.array_source import ArraySource +from .core.grid import Grid, grid_from_points + +__version__ = "0.1.0" + +__all__ = [ + "ChannelKind", + "ChannelSpec", + "Frame", + "Source", + "channels_of_kind", + "ArraySource", + "Grid", + "grid_from_points", + "launch", + "__version__", +] + + +def launch(*args, **kwargs): + """Ouvre la fenêtre Splasher sur une `Source`. Importe l'UI paresseusement.""" + from .ui.app import launch as _launch + + return _launch(*args, **kwargs) diff --git a/splasher/__pycache__/__init__.cpython-312.pyc b/splasher/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c5bb32029af041995487556fcf5b7eb413b47915 Binary files /dev/null and b/splasher/__pycache__/__init__.cpython-312.pyc differ diff --git a/splasher/__pycache__/cli.cpython-312.pyc b/splasher/__pycache__/cli.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2dbefe0ee4341ad2c8f3d1b99fa641b33b0f6392 Binary files /dev/null and b/splasher/__pycache__/cli.cpython-312.pyc differ diff --git a/splasher/__pycache__/demo.cpython-312.pyc b/splasher/__pycache__/demo.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..52d6b0e8723c855a482ed9d5f6b71621811a8a9e Binary files /dev/null and b/splasher/__pycache__/demo.cpython-312.pyc differ diff --git a/splasher/adapters/__init__.py b/splasher/adapters/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..0aae6fc35f0156cfbda29d9c63b31ede0ac20780 --- /dev/null +++ b/splasher/adapters/__init__.py @@ -0,0 +1 @@ +"""Adaptateurs d'entrée optionnels (apairo, …). Aucun n'est requis par le cœur.""" diff --git a/splasher/adapters/__pycache__/__init__.cpython-312.pyc b/splasher/adapters/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5868146895acd965047dfc5077a5de35bc947527 Binary files /dev/null and b/splasher/adapters/__pycache__/__init__.cpython-312.pyc differ diff --git a/splasher/adapters/__pycache__/apairo_source.cpython-312.pyc b/splasher/adapters/__pycache__/apairo_source.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..482bd02c22c50e7edb63ba361177ecd5201b4326 Binary files /dev/null and b/splasher/adapters/__pycache__/apairo_source.cpython-312.pyc differ diff --git a/splasher/adapters/apairo_source.py b/splasher/adapters/apairo_source.py new file mode 100644 index 0000000000000000000000000000000000000000..f53843ec778b648db4884a0064f3b0689900c230 --- /dev/null +++ b/splasher/adapters/apairo_source.py @@ -0,0 +1,72 @@ +"""Adaptateur **optionnel** : un dataset apairo synchrone -> `Source` Splasher. + +apairo n'est pas importé au niveau module : seul `from_path` le charge. La classe +fonctionne en duck-typing sur n'importe quel objet façon apairo +(`is_synchronous`, `keys`, `__len__`, `__getitem__` -> objet avec `.data`/`.timestamp`). +Installer via l'extra : `uv sync --extra apairo`. +""" + +from __future__ import annotations + +import numpy as np + +from ..core.source import ChannelKind, ChannelSpec, Frame + + +def _kind_of(arr: np.ndarray) -> ChannelKind: + """Devine le `ChannelKind` d'un canal apairo d'après la forme du tableau.""" + if arr.ndim == 3 and arr.shape[2] in (1, 3, 4): + return ChannelKind.IMAGE + if arr.shape in ((4, 4), (3, 4)) or arr.shape == (7,): + return ChannelKind.POSE + if arr.ndim == 2 and arr.shape[1] >= 3: + return ChannelKind.POINTCLOUD + return ChannelKind.SCALAR # ex. labels (N,) + + +class ApairoSource: + """Enveloppe un dataset apairo **synchrone** en `Source`.""" + + def __init__(self, dataset, keys: list[str] | None = None) -> None: + if not getattr(dataset, "is_synchronous", False): + raise ValueError( + "ApairoSource requiert un dataset apairo synchrone — " + "appelez ds.synchronize(reference=..., tolerance=...) d'abord." + ) + self._ds = dataset + self._keys = list(keys) if keys is not None else list(dataset.keys) + self._specs = self._classify() + + def _classify(self) -> list[ChannelSpec]: + sample = self._ds[0] + specs = [] + for k in self._keys: + arr = np.asarray(sample.data[k]) + specs.append(ChannelSpec(k, _kind_of(arr), arr.dtype, tuple(arr.shape))) + return specs + + def __len__(self) -> int: + return len(self._ds) + + def __getitem__(self, index: int) -> Frame: + s = self._ds[index] + channels = {k: np.asarray(s.data[k]) for k in self._keys if k in s.data} + return Frame(channels=channels, timestamp=getattr(s, "timestamp", None)) + + def channels(self) -> list[ChannelSpec]: + return list(self._specs) + + @classmethod + def from_path(cls, path: str, *, keys: list[str] | None = None, + reference: str | None = None, tolerance: float = 0.1) -> "ApairoSource": + """Ouvre un `RawDataset` apairo et le synchronise si besoin.""" + import apairo # import paresseux — l'extra `apairo` doit être installé + + ds = apairo.RawDataset(path, keys=keys) if keys else apairo.RawDataset(path) + if not ds.is_synchronous: + if reference is None: + raise ValueError( + "dataset asynchrone : précisez reference= pour la synchronisation." + ) + ds = ds.synchronize(reference=reference, tolerance=tolerance) + return cls(ds, keys=keys) diff --git a/splasher/cli.py b/splasher/cli.py new file mode 100644 index 0000000000000000000000000000000000000000..ed4adb839c9a26575f97bee25be1b8cea031a128 --- /dev/null +++ b/splasher/cli.py @@ -0,0 +1,38 @@ +"""CLI Splasher. + + splasher demo # source synthétique (zéro donnée externe) + splasher --adapter apairo # via l'adaptateur apairo (extra optionnel) +""" + +from __future__ import annotations + +import argparse +import sys + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(prog="splasher", description=__doc__) + parser.add_argument("path", nargs="?", help="chemin du dataset, ou 'demo'") + parser.add_argument("--adapter", choices=["apairo"], help="adaptateur d'entrée") + args = parser.parse_args(argv) + + from . import launch + + if args.path in (None, "demo"): + from .demo import make_demo_source + + launch(make_demo_source(), title="Splasher — démo") + return 0 + + if args.adapter == "apairo": + from .adapters.apairo_source import ApairoSource + + launch(ApairoSource.from_path(args.path), title=f"Splasher — {args.path}") + return 0 + + parser.error("précise un --adapter (ex. --adapter apairo) ou utilise 'demo'") + return 2 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/splasher/core/__init__.py b/splasher/core/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..79c8732fd5a6070e260d4a35e2d6caa2b74b7137 --- /dev/null +++ b/splasher/core/__init__.py @@ -0,0 +1 @@ +"""Cœur de Splasher : protocole d'entrée, grille, projection, cibles de labels. numpy uniquement.""" diff --git a/splasher/core/__pycache__/__init__.cpython-312.pyc b/splasher/core/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2e3b21759376fdcee6943655583914dfbec1adf4 Binary files /dev/null and b/splasher/core/__pycache__/__init__.cpython-312.pyc differ diff --git a/splasher/core/__pycache__/accumulate.cpython-312.pyc b/splasher/core/__pycache__/accumulate.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..bda14f1b3aecb2f3438020857404094e00c829da Binary files /dev/null and b/splasher/core/__pycache__/accumulate.cpython-312.pyc differ diff --git a/splasher/core/__pycache__/array_source.cpython-312.pyc b/splasher/core/__pycache__/array_source.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c9eb30c7f76591d7147d1d14849331e56e5735ea Binary files /dev/null and b/splasher/core/__pycache__/array_source.cpython-312.pyc differ diff --git a/splasher/core/__pycache__/colormap.cpython-312.pyc b/splasher/core/__pycache__/colormap.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c615a8e4a2781877f197f20851a5b59653afe55c Binary files /dev/null and b/splasher/core/__pycache__/colormap.cpython-312.pyc differ diff --git a/splasher/core/__pycache__/grid.cpython-312.pyc b/splasher/core/__pycache__/grid.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..bfd0601e012ef0b6c869f30850e3b534e9e9152e Binary files /dev/null and b/splasher/core/__pycache__/grid.cpython-312.pyc differ diff --git a/splasher/core/__pycache__/io.cpython-312.pyc b/splasher/core/__pycache__/io.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c53cfc7326004ee32687819c7850fdd12328e2eb Binary files /dev/null and b/splasher/core/__pycache__/io.cpython-312.pyc differ diff --git a/splasher/core/__pycache__/labels.cpython-312.pyc b/splasher/core/__pycache__/labels.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c01177a2e98b478e46b5c944a998b0a9ff822751 Binary files /dev/null and b/splasher/core/__pycache__/labels.cpython-312.pyc differ diff --git a/splasher/core/__pycache__/poses.cpython-312.pyc b/splasher/core/__pycache__/poses.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..53971431719b48da7b977f5722cff2139a90ea4b Binary files /dev/null and b/splasher/core/__pycache__/poses.cpython-312.pyc differ diff --git a/splasher/core/__pycache__/projection.cpython-312.pyc b/splasher/core/__pycache__/projection.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..de38edc5d46b6c5db69fd823d9e1d94e3acd113a Binary files /dev/null and b/splasher/core/__pycache__/projection.cpython-312.pyc differ diff --git a/splasher/core/__pycache__/source.cpython-312.pyc b/splasher/core/__pycache__/source.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1108af6b6bc0fcacdb08579b0b0643584c48a172 Binary files /dev/null and b/splasher/core/__pycache__/source.cpython-312.pyc differ diff --git a/splasher/core/__pycache__/target.cpython-312.pyc b/splasher/core/__pycache__/target.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..cff93f38a166d6245c4b9a68d4fa5cb5c68bf6d9 Binary files /dev/null and b/splasher/core/__pycache__/target.cpython-312.pyc differ diff --git a/splasher/core/accumulate.py b/splasher/core/accumulate.py new file mode 100644 index 0000000000000000000000000000000000000000..2d55d88df2f6c06a2ec51efa6f161a0412947910 --- /dev/null +++ b/splasher/core/accumulate.py @@ -0,0 +1,91 @@ +"""Cumul de frames par registration des poses, avec traçabilité pour le décumul. + +On accumule une fenêtre de frames **dans le repère du frame de référence** (le frame +courant) : chaque frame `j` est ramené par `inv(pose_ref) @ pose_j`. Chaque point +accumulé garde : +- `frame_id` : frame source, +- `chan_id` : indice du canal nuage source (dans `cloud_keys`), +- `point_id` : indice du point dans **la concaténation complète** des canaux nuage de + ce frame (ordre = `cloud_keys`), fixe quelle que soit la visibilité des canaux. + +Cela permet de **décumuler** des labels peints sur le nuage cumulé vers chaque frame +d'origine, et de **filtrer par canal** sans jamais désaligner les labels points +(qui restent dimensionnés sur la concaténation complète). +""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +import numpy as np + +from .poses import invert, pose_to_matrix, transform_points + + +@dataclass +class Accumulation: + points: np.ndarray # (M, 3+) dans le repère de référence + frame_id: np.ndarray # (M,) frame source + chan_id: np.ndarray # (M,) indice de canal nuage source + point_id: np.ndarray # (M,) indice dans la concaténation complète du frame + counts: dict[int, int] = field(default_factory=dict) # frame -> nb total de points + + @property + def xy(self) -> np.ndarray: + return self.points[:, :2] + + def visible_mask(self, visible_chan_indices) -> np.ndarray: + """Masque des points dont le canal est dans `visible_chan_indices`.""" + if len(self.chan_id) == 0: + return np.zeros(0, dtype=bool) + return np.isin(self.chan_id, np.asarray(list(visible_chan_indices), dtype=np.int64)) + + +def window_indices(ref_idx: int, radius: int, n_frames: int) -> list[int]: + """Fenêtre `[ref-radius, ref+radius]` bornée à `[0, n_frames)`.""" + lo = max(0, ref_idx - radius) + hi = min(n_frames, ref_idx + radius + 1) + return list(range(lo, hi)) + + +def accumulate(source, ref_idx: int, indices: list[int], cloud_keys: list[str], + pose_key: str | None = None) -> Accumulation: + """Accumule `indices` dans le repère de `ref_idx`. `pose_key=None` -> identité.""" + p_ref_inv = None + if pose_key is not None: + p_ref_inv = invert(pose_to_matrix(source[ref_idx].channels[pose_key])) + + pts, fids, cids, pids = [], [], [], [] + counts: dict[int, int] = {} + for j in indices: + frame = source[j] + if pose_key is not None and j != ref_idx: + T = p_ref_inv @ pose_to_matrix(frame.channels[pose_key]) + else: + T = None # identité (frame de référence, ou pas de pose) + + offset = 0 + for ci, key in enumerate(cloud_keys): + p = frame.channels.get(key) + if p is None or len(p) == 0: + continue + q = np.asarray(p, dtype=np.float64).copy() if T is None else transform_points(p, T) + m = len(p) + pts.append(q) + fids.append(np.full(m, j, dtype=np.int64)) + cids.append(np.full(m, ci, dtype=np.int64)) + pids.append(offset + np.arange(m, dtype=np.int64)) + offset += m + counts[j] = offset + + if pts: + return Accumulation( + np.concatenate(pts, axis=0), + np.concatenate(fids), + np.concatenate(cids), + np.concatenate(pids), + counts, + ) + return Accumulation( + np.zeros((0, 3)), np.zeros(0, np.int64), np.zeros(0, np.int64), np.zeros(0, np.int64), counts + ) diff --git a/splasher/core/array_source.py b/splasher/core/array_source.py new file mode 100644 index 0000000000000000000000000000000000000000..5ca9f1681785206eeda96bb19ef733791b3823bc --- /dev/null +++ b/splasher/core/array_source.py @@ -0,0 +1,55 @@ +"""`ArraySource` — une `Source` construite depuis des tableaux numpy en mémoire. + +C'est l'entrée par défaut, sans aucune dépendance : utile pour les démos, les +tests, et tout pipeline qui produit déjà des arrays. Aucune I/O cachée. +""" + +from __future__ import annotations + +from typing import Mapping, Sequence + +import numpy as np + +from .source import ChannelSpec, Frame, Source + + +class ArraySource(Source): + """Source synchrone en mémoire. + + Parameters + ---------- + specs: + Description des canaux (`ChannelSpec`). L'ordre est conservé. + frames: + Une séquence de dicts `{nom_canal: np.ndarray}`, un par pas de temps. + timestamps: + Optionnel ; horodatages par frame. `None` (défaut) = synchrone. + """ + + def __init__( + self, + specs: Sequence[ChannelSpec], + frames: Sequence[Mapping[str, np.ndarray]], + timestamps: Sequence[float] | None = None, + ) -> None: + self._specs = list(specs) + self._frames = [dict(f) for f in frames] + if timestamps is not None and len(timestamps) != len(self._frames): + raise ValueError("timestamps doit avoir la même longueur que frames") + self._timestamps = None if timestamps is None else [float(t) for t in timestamps] + + names = {s.name for s in self._specs} + for i, fr in enumerate(self._frames): + missing = names - set(fr.keys()) + if missing: + raise ValueError(f"frame {i}: canaux manquants {sorted(missing)}") + + def __len__(self) -> int: + return len(self._frames) + + def __getitem__(self, index: int) -> Frame: + ts = None if self._timestamps is None else self._timestamps[index] + return Frame(channels=dict(self._frames[index]), timestamp=ts) + + def channels(self) -> list[ChannelSpec]: + return list(self._specs) diff --git a/splasher/core/colormap.py b/splasher/core/colormap.py new file mode 100644 index 0000000000000000000000000000000000000000..42552c5c517d1ed216c5db283ffe5cd054f8eccf --- /dev/null +++ b/splasher/core/colormap.py @@ -0,0 +1,51 @@ +"""Petit colormap viridis numpy-pur (pas de matplotlib) : valeurs -> RGBA float [0,1].""" + +from __future__ import annotations + +import numpy as np + +# Ancres viridis approximées (0 -> 1). +_VIRIDIS = ( + np.array( + [ + [68, 1, 84], + [59, 82, 139], + [33, 145, 140], + [94, 201, 98], + [253, 231, 37], + ], + dtype=np.float32, + ) + / 255.0 +) + + +def colormap(values, *, vmin: float | None = None, vmax: float | None = None, + alpha: float = 1.0, lut: np.ndarray = _VIRIDIS) -> np.ndarray: + """Mappe `values` (1D) vers un tableau RGBA `(N, 4)` float32 dans [0, 1]. + + `vmin`/`vmax` par défaut = min/max finis des valeurs. + """ + v = np.asarray(values, dtype=np.float32).ravel() + finite = np.isfinite(v) + if vmin is None: + vmin = float(v[finite].min()) if finite.any() else 0.0 + if vmax is None: + vmax = float(v[finite].max()) if finite.any() else 1.0 + if vmax <= vmin: + vmax = vmin + 1.0 + + t = np.clip((v - vmin) / (vmax - vmin), 0.0, 1.0) + t = np.where(finite, t, 0.0) + + n = len(lut) - 1 + pos = t * n + lo = np.floor(pos).astype(np.intp) + hi = np.minimum(lo + 1, n) + frac = (pos - lo)[:, None] + rgb = lut[lo] * (1.0 - frac) + lut[hi] * frac + + out = np.empty((v.shape[0], 4), dtype=np.float32) + out[:, :3] = rgb + out[:, 3] = alpha + return out diff --git a/splasher/core/grid.py b/splasher/core/grid.py new file mode 100644 index 0000000000000000000000000000000000000000..9aac282b6fff3ef7604e289e6b3837e8fa357bd7 --- /dev/null +++ b/splasher/core/grid.py @@ -0,0 +1,111 @@ +"""`Grid` — la grille de carrés vue-de-dessus (BEV), définie en unités monde. + +C'est la première chose qu'on conçoit : une étendue monde `(xmin..xmax, ymin..ymax)` +et la taille d'un carré `cell_size` (mètres). On en déduit `cols × rows`. La grille +fournit le mapping monde <-> cellule, un raster vide, et les segments de lignes pour +l'affichage. + +Conventions : +- `j` (colonne) indexe `x` : `j = floor((x - xmin) / cell_size)` +- `i` (ligne) indexe `y` : `i = floor((y - ymin) / cell_size)` +- un raster a la forme `(rows, cols)`, indexé `raster[i, j]` +- l'origine `(xmin, ymin)` est en bas-gauche, `y` vers le haut. +""" + +from __future__ import annotations + +import math +from dataclasses import dataclass + +import numpy as np + + +@dataclass(frozen=True) +class Grid: + xmin: float + xmax: float + ymin: float + ymax: float + cell_size: float + + def __post_init__(self) -> None: + if self.cell_size <= 0: + raise ValueError("cell_size doit être > 0") + if self.xmax <= self.xmin or self.ymax <= self.ymin: + raise ValueError("étendue invalide (xmax > xmin et ymax > ymin requis)") + + # --- dimensions ------------------------------------------------------- + @property + def cols(self) -> int: + return max(1, math.ceil((self.xmax - self.xmin) / self.cell_size)) + + @property + def rows(self) -> int: + return max(1, math.ceil((self.ymax - self.ymin) / self.cell_size)) + + @property + def shape(self) -> tuple[int, int]: + return (self.rows, self.cols) + + @property + def width(self) -> float: + """Largeur réelle couverte par les carrés (cols * cell_size).""" + return self.cols * self.cell_size + + @property + def height(self) -> float: + return self.rows * self.cell_size + + @property + def extent(self) -> tuple[float, float, float, float]: + return (self.xmin, self.xmax, self.ymin, self.ymax) + + # --- mapping monde <-> cellule --------------------------------------- + def world_to_cell(self, xy: np.ndarray) -> tuple[np.ndarray, np.ndarray]: + """`xy` (N, 2) -> (`ij` (N, 2) int [ligne, colonne], `valid` (N,) bool).""" + xy = np.asarray(xy, dtype=np.float64).reshape(-1, 2) + j = np.floor((xy[:, 0] - self.xmin) / self.cell_size).astype(np.intp) + i = np.floor((xy[:, 1] - self.ymin) / self.cell_size).astype(np.intp) + valid = (j >= 0) & (j < self.cols) & (i >= 0) & (i < self.rows) + return np.stack([i, j], axis=1), valid + + def cell_to_world(self, i: int, j: int) -> tuple[float, float]: + """Centre monde de la cellule `(i, j)`.""" + x = self.xmin + (j + 0.5) * self.cell_size + y = self.ymin + (i + 0.5) * self.cell_size + return (x, y) + + def empty_raster(self, fill: int = 0, dtype=np.int32) -> np.ndarray: + return np.full((self.rows, self.cols), fill, dtype=dtype) + + # --- affichage -------------------------------------------------------- + def image_rect(self) -> tuple[float, float, float, float]: + """`(x, y, w, h)` pour positionner un `ImageItem (rows, cols)` en coords monde.""" + return (self.xmin, self.ymin, self.width, self.height) + + def line_segments(self) -> tuple[np.ndarray, np.ndarray]: + """Segments des lignes de la grille pour un tracé `connect='pairs'`.""" + x0, y0 = self.xmin, self.ymin + x1, y1 = self.xmin + self.width, self.ymin + self.height + vx = self.xmin + self.cell_size * np.arange(self.cols + 1) + hy = self.ymin + self.cell_size * np.arange(self.rows + 1) + + xs_v = np.repeat(vx, 2) + ys_v = np.tile([y0, y1], self.cols + 1) + xs_h = np.tile([x0, x1], self.rows + 1) + ys_h = np.repeat(hy, 2) + + xs = np.concatenate([xs_v, xs_h]).astype(np.float64) + ys = np.concatenate([ys_v, ys_h]).astype(np.float64) + return xs, ys + + +def grid_from_points(xy: np.ndarray, cell_size: float = 1.0, + margin: float = 2.0) -> Grid: + """Grille par défaut englobant un nuage de points top-down `xy` (N, 2).""" + xy = np.asarray(xy, dtype=np.float64).reshape(-1, 2) + if len(xy) == 0: + return Grid(-10.0, 10.0, -10.0, 10.0, cell_size) + lo = np.floor(xy.min(axis=0) - margin) + hi = np.ceil(xy.max(axis=0) + margin) + return Grid(float(lo[0]), float(hi[0]), float(lo[1]), float(hi[1]), cell_size) diff --git a/splasher/core/io.py b/splasher/core/io.py new file mode 100644 index 0000000000000000000000000000000000000000..1dde717913d7873dc2ea2e0ba26cf6589378137f --- /dev/null +++ b/splasher/core/io.py @@ -0,0 +1,101 @@ +"""Sauvegarde/chargement d'une session de labels — fichiers autonomes. + +Format sur disque (dossier de sortie) : + + session.json # grille (étendue, cell_size) + jeu de classes + grid/frame_00007.npy # raster d'ids (rows, cols), un par sample labélisé + grid/frame_00007.png # même raster colorisé (aperçu, y vers le haut) + grid/global.npy # raster global (si labélisé) + points/frame_00007.npy# labels (N,) int64 par frame (segmentation) + +Les `.npy`/`.json` n'utilisent que numpy + stdlib. Le `.png` (aperçu) passe par Qt +si disponible, sinon il est simplement omis. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import numpy as np + +from .grid import Grid +from .labels import LabelSet + + +def _save_png(path: Path, rgba: np.ndarray) -> bool: + try: + from PySide6.QtGui import QImage + except Exception: + return False + arr = np.ascontiguousarray(np.flipud(rgba).astype(np.uint8)) # y vers le haut à l'écran + h, w = arr.shape[:2] + img = QImage(arr.data, w, h, 4 * w, QImage.Format_RGBA8888) + return bool(img.copy().save(str(path))) + + +def save_session(out_dir, *, grid: Grid, labelset: LabelSet, + grid_target=None, point_target=None) -> Path: + out = Path(out_dir) + out.mkdir(parents=True, exist_ok=True) + + meta = { + "grid": { + "xmin": grid.xmin, "xmax": grid.xmax, + "ymin": grid.ymin, "ymax": grid.ymax, + "cell_size": grid.cell_size, "rows": grid.rows, "cols": grid.cols, + }, + "labels": labelset.to_dict(), + } + (out / "session.json").write_text(json.dumps(meta, indent=2, ensure_ascii=False)) + + if grid_target is not None: + gd = out / "grid" + gd.mkdir(exist_ok=True) + for fi, raster in grid_target.rasters().items(): + np.save(gd / f"frame_{fi:05d}.npy", raster) + _save_png(gd / f"frame_{fi:05d}.png", labelset.colorize(raster, alpha=255)) + glob = grid_target.global_raster() + if (glob != labelset.ignore_id).any(): + np.save(gd / "global.npy", glob) + _save_png(gd / "global.png", labelset.colorize(glob, alpha=255)) + + if point_target is not None: + pd = out / "points" + pd.mkdir(exist_ok=True) + for fi, lab in point_target.all_labels().items(): + np.save(pd / f"frame_{fi:05d}.npy", lab) + + return out + + +def _frame_idx(path: Path) -> int: + return int(path.stem.split("_")[1]) + + +def load_session(out_dir) -> dict: + """Renvoie `{grid, labelset, grid_labels: {i: raster}, point_labels: {i: (N,)}}`.""" + out = Path(out_dir) + meta = json.loads((out / "session.json").read_text()) + g = meta["grid"] + grid = Grid(g["xmin"], g["xmax"], g["ymin"], g["ymax"], g["cell_size"]) + labelset = LabelSet.from_dict(meta["labels"]) + + grid_labels: dict[int, np.ndarray] = {} + gd = out / "grid" + if gd.is_dir(): + for p in sorted(gd.glob("frame_*.npy")): + grid_labels[_frame_idx(p)] = np.load(p) + + point_labels: dict[int, np.ndarray] = {} + pd = out / "points" + if pd.is_dir(): + for p in sorted(pd.glob("frame_*.npy")): + point_labels[_frame_idx(p)] = np.load(p) + + return { + "grid": grid, + "labelset": labelset, + "grid_labels": grid_labels, + "point_labels": point_labels, + } diff --git a/splasher/core/labels.py b/splasher/core/labels.py new file mode 100644 index 0000000000000000000000000000000000000000..ff9cde7beb8903636d8b8f492cf67f743b4cc924 --- /dev/null +++ b/splasher/core/labels.py @@ -0,0 +1,96 @@ +"""`LabelSet` — l'ensemble des classes de labélisation (id, nom, couleur). + +Générique : aucune classe n'est imposée. Un défaut « traversabilité » est fourni, +mais on peut charger/sauver n'importe quel jeu de classes en JSON. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from pathlib import Path + +import numpy as np + +RGB = tuple[int, int, int] + + +@dataclass(frozen=True) +class LabelClass: + id: int + name: str + color: RGB + + +class LabelSet: + def __init__(self, classes: list[LabelClass], ignore_id: int = 0) -> None: + self.classes = list(classes) + self.ignore_id = ignore_id + self._by_id = {c.id: c for c in self.classes} + + @property + def max_id(self) -> int: + return max((c.id for c in self.classes), default=0) + + @property + def paintable(self) -> list[LabelClass]: + """Classes assignables (toutes sauf `ignore`).""" + return [c for c in self.classes if c.id != self.ignore_id] + + def color_of(self, class_id: int) -> RGB: + c = self._by_id.get(class_id) + return c.color if c else (0, 0, 0) + + def name_of(self, class_id: int) -> str: + c = self._by_id.get(class_id) + return c.name if c else str(class_id) + + def lut(self, alpha: int = 255, max_id: int | None = None) -> np.ndarray: + """LUT RGBA `(K, 4)` uint8 indexée par id. `ignore_id` -> alpha 0.""" + top = self.max_id if max_id is None else max(max_id, self.max_id) + lut = np.zeros((top + 1, 4), dtype=np.uint8) + for c in self.classes: + if c.id == self.ignore_id or c.id < 0 or c.id > top: + continue + lut[c.id, :3] = c.color + lut[c.id, 3] = alpha + return lut + + def colorize(self, raster: np.ndarray, alpha: int = 255) -> np.ndarray: + """Raster d'ids `(rows, cols)` -> image RGBA `(rows, cols, 4)` uint8.""" + max_id = int(raster.max()) if raster.size else 0 + return self.lut(alpha=alpha, max_id=max_id)[raster] + + # --- (dé)sérialisation ------------------------------------------------ + def to_dict(self) -> dict: + return { + "ignore_id": self.ignore_id, + "classes": [ + {"id": c.id, "name": c.name, "color": list(c.color)} for c in self.classes + ], + } + + def save(self, path: str | Path) -> None: + Path(path).write_text(json.dumps(self.to_dict(), indent=2, ensure_ascii=False)) + + @classmethod + def from_dict(cls, d: dict) -> "LabelSet": + classes = [LabelClass(c["id"], c["name"], tuple(c["color"])) for c in d["classes"]] + return cls(classes, ignore_id=d.get("ignore_id", 0)) + + @classmethod + def load(cls, path: str | Path) -> "LabelSet": + return cls.from_dict(json.loads(Path(path).read_text())) + + @classmethod + def default(cls) -> "LabelSet": + """Jeu par défaut orienté traversabilité (modifiable / remplaçable).""" + return cls( + [ + LabelClass(0, "non labélisé", (0, 0, 0)), + LabelClass(1, "traversable", (60, 200, 70)), + LabelClass(2, "obstacle", (220, 50, 45)), + LabelClass(3, "incertain", (235, 170, 30)), + ], + ignore_id=0, + ) diff --git a/splasher/core/poses.py b/splasher/core/poses.py new file mode 100644 index 0000000000000000000000000000000000000000..ead8f54e17ba0d9d4cc9195ef76be25ac0f87dad --- /dev/null +++ b/splasher/core/poses.py @@ -0,0 +1,60 @@ +"""Poses rigides : conversion en matrice 4x4, inversion, transformation de points. + +Réimplémenté ici (≈30 lignes) pour ne pas dépendre d'apairo_visu / Open3D. +Formats acceptés : `(4, 4)`, `(3, 4)`, ou vecteur `(7,)` `[x, y, z, qx, qy, qz, qw]`. +""" + +from __future__ import annotations + +import numpy as np + + +def _quat_to_R(q: np.ndarray) -> np.ndarray: + x, y, z, w = q + n = x * x + y * y + z * z + w * w + if n < 1e-12: + return np.eye(3) + s = 2.0 / n + return np.array( + [ + [1 - s * (y * y + z * z), s * (x * y - z * w), s * (x * z + y * w)], + [s * (x * y + z * w), 1 - s * (x * x + z * z), s * (y * z - x * w)], + [s * (x * z - y * w), s * (y * z + x * w), 1 - s * (x * x + y * y)], + ] + ) + + +def pose_to_matrix(pose: np.ndarray) -> np.ndarray: + """Normalise une pose en matrice homogène `(4, 4)` float64.""" + pose = np.asarray(pose, dtype=np.float64) + if pose.shape == (4, 4): + return pose.copy() + if pose.shape == (3, 4): + T = np.eye(4) + T[:3, :4] = pose + return T + if pose.shape == (7,): + T = np.eye(4) + T[:3, :3] = _quat_to_R(pose[3:]) + T[:3, 3] = pose[:3] + return T + raise ValueError(f"forme de pose non supportée : {pose.shape}") + + +def invert(T: np.ndarray) -> np.ndarray: + """Inverse d'une transformation rigide `(4, 4)`.""" + R = T[:3, :3] + t = T[:3, 3] + Ti = np.eye(4) + Ti[:3, :3] = R.T + Ti[:3, 3] = -R.T @ t + return Ti + + +def transform_points(points: np.ndarray, T: np.ndarray) -> np.ndarray: + """Applique `T` aux colonnes xyz de `points` (N, 3+) ; les colonnes en plus sont conservées.""" + if len(points) == 0: + return points.copy() + out = np.asarray(points, dtype=np.float64).copy() + out[:, :3] = points[:, :3] @ T[:3, :3].T + T[:3, 3] + return out diff --git a/splasher/core/projection.py b/splasher/core/projection.py new file mode 100644 index 0000000000000000000000000000000000000000..0b378a9261fdbd370667b791b09aa4abdd2bd3b0 --- /dev/null +++ b/splasher/core/projection.py @@ -0,0 +1,86 @@ +"""Projection BEV : nuage de points -> cellules de la grille, et sélection par rectangle. + +Tout est vectorisé numpy. Sert : +- la sous-couche de la vue de dessus (densité / hauteur par cellule), +- la cible Grid (cellules couvertes par un rectangle), +- la cible Points + le surlignage (masque des points dans un rectangle). +""" + +from __future__ import annotations + +import numpy as np + +from .colormap import colormap +from .grid import Grid + +Rect = tuple[float, float, float, float] # (x0, y0, x1, y1) en coords monde + + +def points_to_cells(xy: np.ndarray, grid: Grid): + """Raccourci vers `grid.world_to_cell` : `xy` (N, 2) -> (`ij` (N, 2), `valid`).""" + return grid.world_to_cell(xy) + + +def _reduce_per_cell(points: np.ndarray, grid: Grid, op: str) -> np.ndarray: + """Réduit une grandeur par cellule. `op` = 'max_z' ou 'count'. Cellules vides = NaN.""" + out = np.full(grid.rows * grid.cols, -np.inf if op == "max_z" else 0.0, dtype=np.float64) + if len(points): + ij, valid = grid.world_to_cell(points[:, :2]) + flat = ij[valid, 0] * grid.cols + ij[valid, 1] + if op == "max_z": + np.maximum.at(out, flat, points[valid, 2]) + else: + np.add.at(out, flat, 1.0) + out = out.reshape(grid.shape) + if op == "max_z": + out[~np.isfinite(out)] = np.nan + else: + out[out == 0.0] = np.nan + return out + + +def bev_max_height(points: np.ndarray, grid: Grid) -> np.ndarray: + """`(rows, cols)` float : hauteur max (z) par cellule, NaN si vide.""" + return _reduce_per_cell(points, grid, "max_z") + + +def bev_count(points: np.ndarray, grid: Grid) -> np.ndarray: + """`(rows, cols)` float : nombre de points par cellule, NaN si vide.""" + return _reduce_per_cell(points, grid, "count") + + +def bev_image(scalar_field: np.ndarray, *, alpha: int = 210) -> np.ndarray: + """Colorise un champ `(rows, cols)` (NaN = transparent) en RGBA uint8 `(rows, cols, 4)`.""" + rows, cols = scalar_field.shape + rgba = np.zeros((rows, cols, 4), dtype=np.uint8) + filled = np.isfinite(scalar_field) + if filled.any(): + colors = colormap(scalar_field[filled]) + rgba[filled, :3] = (colors[:, :3] * 255).astype(np.uint8) + rgba[filled, 3] = alpha + return rgba + + +def cells_in_rect(rect: Rect, grid: Grid) -> tuple[slice, slice]: + """Cellules (lignes, colonnes) couvertes par un rectangle monde -> slices `(i, j)`.""" + x0, y0, x1, y1 = rect + x0, x1 = sorted((x0, x1)) + y0, y1 = sorted((y0, y1)) + j0 = int(np.clip(np.floor((x0 - grid.xmin) / grid.cell_size), 0, grid.cols)) + j1 = int(np.clip(np.ceil((x1 - grid.xmin) / grid.cell_size), 0, grid.cols)) + i0 = int(np.clip(np.floor((y0 - grid.ymin) / grid.cell_size), 0, grid.rows)) + i1 = int(np.clip(np.ceil((y1 - grid.ymin) / grid.cell_size), 0, grid.rows)) + return slice(i0, i1), slice(j0, j1) + + +def points_in_rect(xy: np.ndarray, rect: Rect) -> np.ndarray: + """Masque booléen `(N,)` des points dont `(x, y)` tombe dans le rectangle monde.""" + if xy is None or len(xy) == 0: + return np.zeros(0, dtype=bool) + x0, y0, x1, y1 = rect + x0, x1 = sorted((x0, x1)) + y0, y1 = sorted((y0, y1)) + xy = np.asarray(xy) + return ( + (xy[:, 0] >= x0) & (xy[:, 0] <= x1) & (xy[:, 1] >= y0) & (xy[:, 1] <= y1) + ) diff --git a/splasher/core/source.py b/splasher/core/source.py new file mode 100644 index 0000000000000000000000000000000000000000..7bacbec37c0918a744bd583bdc6c30f4559f0344 --- /dev/null +++ b/splasher/core/source.py @@ -0,0 +1,75 @@ +"""Protocole d'entrée de Splasher — le point central de la généricité. + +Une `Source` est un *dataset synchrone* : à chaque index temporel, elle rend un +`Frame` = un pack de canaux nommés (tableaux numpy), chacun typé par un +`ChannelKind`. C'est tout ce que l'outil exige. apairo, des fichiers, des arrays +en mémoire… ne sont que des manières de produire une `Source`. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum +from typing import Iterable, Protocol, runtime_checkable + +import numpy as np + + +class ChannelKind(Enum): + """Nature d'un canal — pilote la vue qui l'affiche et la manière de le labéliser.""" + + POINTCLOUD = "pointcloud" # (N, 3) ou (N, 3+C) : [x, y, z, ...] + IMAGE = "image" # (H, W) ou (H, W, C) uint8 + POSE = "pose" # (4, 4) ou (7,) [x,y,z, qx,qy,qz,qw] : placement monde + SCALAR = "scalar" # autre tableau (réservé / extensible) + + def __repr__(self) -> str: # affichage compact + return f"ChannelKind.{self.name}" + + +@dataclass(frozen=True) +class ChannelSpec: + """Métadonnées d'un canal. `shape` peut contenir des `None` pour les dims variables.""" + + name: str + kind: ChannelKind + dtype: np.dtype | None = None + shape: tuple[int | None, ...] | None = None + + +@dataclass +class Frame: + """Un pas de temps synchronisé : tous les canaux à un même instant.""" + + channels: dict[str, np.ndarray] + timestamp: float | None = None # None = synchrone (convention apairo) + + def __getitem__(self, key: str) -> np.ndarray: + return self.channels[key] + + def __contains__(self, key: str) -> bool: + return key in self.channels + + def keys(self): + return self.channels.keys() + + +@runtime_checkable +class Source(Protocol): + """Dataset synchrone : longueur, accès indexé à un `Frame`, et description des canaux.""" + + def __len__(self) -> int: ... + + def __getitem__(self, index: int) -> Frame: ... + + def channels(self) -> list[ChannelSpec]: ... + + +def channels_of_kind(source_or_specs, kind: ChannelKind) -> list[str]: + """Noms des canaux d'un `ChannelKind` donné, depuis une `Source` ou une liste de specs.""" + specs: Iterable[ChannelSpec] + if hasattr(source_or_specs, "channels"): + specs = source_or_specs.channels() + else: + specs = source_or_specs + return [s.name for s in specs if s.kind == kind] diff --git a/splasher/core/target.py b/splasher/core/target.py new file mode 100644 index 0000000000000000000000000000000000000000..c34ccbc45a62dacd9276d74eb493b786bda90671 --- /dev/null +++ b/splasher/core/target.py @@ -0,0 +1,169 @@ +"""Cibles de labélisation : ce que produit la sélection d'un rectangle. + +- `GridTarget` : raster d'ids par sample — sortie « grille telle quelle ». +- `PointTarget` : labels `(N,)` par frame (segmentation du nuage). + +**Historique par frame** : chaque frame possède sa propre pile d'annulation. `undo` +prend le frame courant et n'annule que sa dernière action. Un coup de pinceau cumulé +(décumulé sur plusieurs frames) est enregistré, de façon atomique, sous le frame de +référence où il a été peint. +""" + +from __future__ import annotations + +from typing import Protocol + +import numpy as np + +from .grid import Grid +from .projection import Rect, cells_in_rect, points_in_rect + + +class LabelTarget(Protocol): + name: str + + def undo(self, frame_idx: int): ... + + +class GridTarget: + """Rasters d'ids par sample (+ un raster global). Cible « grille ».""" + + name = "grid" + + def __init__(self, grid: Grid, ignore_id: int = 0) -> None: + self.grid = grid + self.ignore_id = ignore_id + self._rasters: dict[int, np.ndarray] = {} + self._global = grid.empty_raster(ignore_id) + self._undo: dict[int, list] = {} + + def raster(self, frame_idx: int, scope: str = "sample") -> np.ndarray: + if scope == "global": + return self._global + r = self._rasters.get(frame_idx) + if r is None: + r = self.grid.empty_raster(self.ignore_id) + self._rasters[frame_idx] = r + return r + + def has(self, frame_idx: int, scope: str = "sample") -> bool: + return scope == "global" or frame_idx in self._rasters + + def rasters(self) -> dict[int, np.ndarray]: + return self._rasters + + def global_raster(self) -> np.ndarray: + return self._global + + def load_rasters(self, rasters: dict[int, np.ndarray], global_raster=None) -> None: + self._rasters = {int(k): np.asarray(v) for k, v in rasters.items()} + if global_raster is not None: + self._global = np.asarray(global_raster) + self._undo.clear() + + def apply(self, frame_idx: int, rect: Rect, class_id: int, scope: str = "sample") -> bool: + si, sj = cells_in_rect(rect, self.grid) + if si.start >= si.stop or sj.start >= sj.stop: + return False + return self._set(frame_idx, (si, sj), class_id, scope) + + def apply_mask(self, frame_idx: int, mask: np.ndarray, class_id: int, + scope: str = "sample") -> bool: + """Peint un masque booléen de cellules `(rows, cols)` (sélection).""" + if mask is None or not mask.any(): + return False + return self._set(frame_idx, mask, class_id, scope) + + def _set(self, frame_idx: int, sel, class_id: int, scope: str) -> bool: + target = self.raster(frame_idx, scope) + self._undo.setdefault(frame_idx, []).append((scope, sel, target[sel].copy())) + target[sel] = class_id + return True + + def clear(self, frame_idx: int, scope: str = "sample") -> None: + target = self.raster(frame_idx, scope) + self._undo.setdefault(frame_idx, []).append((scope, (slice(None), slice(None)), target.copy())) + target[:] = self.ignore_id + + def undo(self, frame_idx: int): + stack = self._undo.get(frame_idx) + if not stack: + return None + scope, sel, prev = stack.pop() + self.raster(frame_idx, scope)[sel] = prev + return scope, frame_idx + + +class PointTarget: + """Labels `(N,) int64` par frame. Cible « points » (segmentation du nuage). + + Les labels sont dimensionnés sur la **concaténation complète** des canaux nuage du + frame (ordre fixe), donc indépendants de la visibilité des canaux. + """ + + name = "points" + + def __init__(self, ignore_id: int = 0) -> None: + self.ignore_id = ignore_id + self._labels: dict[int, np.ndarray] = {} + self._undo: dict[int, list] = {} + + def has(self, frame_idx: int) -> bool: + return frame_idx in self._labels + + def labels(self, frame_idx: int, n: int | None = None) -> np.ndarray | None: + lab = self._labels.get(frame_idx) + if (lab is None or (n is not None and len(lab) != n)) and n is not None: + lab = np.full(n, self.ignore_id, dtype=np.int64) + self._labels[frame_idx] = lab + return lab + + def all_labels(self) -> dict[int, np.ndarray]: + return self._labels + + def load_labels(self, labels: dict[int, np.ndarray]) -> None: + self._labels = {int(k): np.asarray(v, dtype=np.int64) for k, v in labels.items()} + self._undo.clear() + + def apply(self, frame_idx: int, rect: Rect, class_id: int, xy: np.ndarray) -> bool: + mask = points_in_rect(xy, rect) + if not mask.any(): + return False + lab = self.labels(frame_idx, len(xy)) + self._undo.setdefault(frame_idx, []).append([(frame_idx, mask, lab[mask].copy())]) + lab[mask] = class_id + return True + + def apply_scatter(self, ref_frame: int, frame_to_sel: dict[int, tuple[np.ndarray, int]], + class_id: int) -> bool: + """Assigne `class_id` à des points répartis sur plusieurs frames (décumul). + + `frame_to_sel` : `{frame_idx: (indices, n_points_du_frame)}`. L'opération est + enregistrée de façon atomique sous `ref_frame` (le frame courant). + """ + changes: list[tuple] = [] + for frame_idx, (indices, n) in frame_to_sel.items(): + if len(indices) == 0: + continue + lab = self.labels(frame_idx, n) + changes.append((frame_idx, indices, lab[indices].copy())) + lab[indices] = class_id + if not changes: + return False + self._undo.setdefault(ref_frame, []).append(changes) + return True + + def clear(self, frame_idx: int, xy: np.ndarray | None = None) -> None: + lab = self._labels.get(frame_idx) + if lab is None: + return + self._undo.setdefault(frame_idx, []).append([(frame_idx, slice(None), lab.copy())]) + lab[:] = self.ignore_id + + def undo(self, frame_idx: int): + stack = self._undo.get(frame_idx) + if not stack: + return None + for f, sel, prev in stack.pop(): + self._labels[f][sel] = prev + return self.name, frame_idx diff --git a/splasher/demo.py b/splasher/demo.py new file mode 100644 index 0000000000000000000000000000000000000000..de6a52952e690b2797fc6266dbad38678a9be555 --- /dev/null +++ b/splasher/demo.py @@ -0,0 +1,95 @@ +"""Source synthétique multi-canaux pour démos/tests — aucune donnée externe. + +Scène « conduite » : l'ego avance en +x. Canaux fournis : +- `lidar` : sol bruité + obstacles (nuage dense), +- `lidar_haut` : points en hauteur au-dessus des obstacles (nuage épars, distinct), +- `camera_avant` / `camera_arriere` : deux images factices, +- `pose` : matrice 4x4 par frame (pour le cumul). +""" + +from __future__ import annotations + +import numpy as np + +from .core.array_source import ArraySource +from .core.source import ChannelKind, ChannelSpec + + +def _make_image(h: int, w: int, t: int, n_frames: int, *, rear: bool = False) -> np.ndarray: + img = np.empty((h, w, 3), dtype=np.uint8) + horizon = h // 2 + img[:horizon] = (140, 110, 90) if rear else (90, 120, 200) # ciel (teinte différente derrière) + img[horizon:] = (70, 110, 70) + for r in range(horizon, h): + frac = (r - horizon) / max(1, h - horizon) + half = int((0.05 + 0.45 * frac) * w) + c = w // 2 + img[r, max(0, c - half):min(w, c + half)] = (60, 60, 64) + # « obstacle » mobile (sens inverse derrière) + prog = t / max(1, n_frames - 1) + bx = int((1 - prog if rear else prog) * (w - 50)) + img[horizon - 30:horizon + 10, bx:bx + 40] = (80, 150, 210) if rear else (210, 80, 60) + return img + + +def make_demo_source(n_frames: int = 40, seed: int = 0) -> ArraySource: + rng = np.random.default_rng(seed) + h, w = 200, 360 + speed = 1.2 + + n_obs = 9 + obs_x = rng.uniform(8.0, 70.0, n_obs) + obs_y = rng.uniform(-12.0, 12.0, n_obs) + obs_r = rng.uniform(0.6, 1.8, n_obs) + obs_h = rng.uniform(1.0, 3.0, n_obs) + + specs = [ + ChannelSpec("lidar", ChannelKind.POINTCLOUD, np.dtype("float32"), (None, 4)), + ChannelSpec("lidar_haut", ChannelKind.POINTCLOUD, np.dtype("float32"), (None, 4)), + ChannelSpec("camera_avant", ChannelKind.IMAGE, np.dtype("uint8"), (h, w, 3)), + ChannelSpec("camera_arriere", ChannelKind.IMAGE, np.dtype("uint8"), (h, w, 3)), + ChannelSpec("pose", ChannelKind.POSE, np.dtype("float32"), (4, 4)), + ] + + frames: list[dict[str, np.ndarray]] = [] + for t in range(n_frames): + ex = speed * t + + ng = 14000 + gx = rng.uniform(1.0, 40.0, ng) + gy = rng.uniform(-20.0, 20.0, ng) + gz = rng.normal(0.0, 0.03, ng) + ground = [np.stack([gx, gy, gz, rng.uniform(0.1, 0.3, ng)], axis=1)] + high = [] + + for k in range(n_obs): + xr = obs_x[k] - ex + if 0.0 < xr < 40.0: + m = 500 + px = xr + rng.normal(0.0, obs_r[k], m) + py = obs_y[k] + rng.normal(0.0, obs_r[k], m) + pz = rng.uniform(0.0, obs_h[k], m) + ground.append(np.stack([px, py, pz, rng.uniform(0.6, 1.0, m)], axis=1)) + # nuage "haut" : points épars au-dessus de l'obstacle + mh = 80 + hx = xr + rng.normal(0.0, obs_r[k] * 0.5, mh) + hy = obs_y[k] + rng.normal(0.0, obs_r[k] * 0.5, mh) + hz = obs_h[k] + rng.uniform(1.0, 3.0, mh) + high.append(np.stack([hx, hy, hz, rng.uniform(0.2, 0.5, mh)], axis=1)) + + lidar = np.concatenate(ground, axis=0).astype(np.float32) + lidar_haut = (np.concatenate(high, axis=0) if high + else np.zeros((0, 4))).astype(np.float32) + + pose = np.eye(4, dtype=np.float32) + pose[0, 3] = ex + + frames.append({ + "lidar": lidar, + "lidar_haut": lidar_haut, + "camera_avant": _make_image(h, w, t, n_frames), + "camera_arriere": _make_image(h, w, t, n_frames, rear=True), + "pose": pose, + }) + + return ArraySource(specs, frames) diff --git a/splasher/ui/__init__.py b/splasher/ui/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..bc1fd2fd42a098072d7b1a5fa257ba7c9514223d --- /dev/null +++ b/splasher/ui/__init__.py @@ -0,0 +1 @@ +"""UI Splasher (PySide6 + pyqtgraph). Importée paresseusement par `splasher.launch`.""" diff --git a/splasher/ui/__pycache__/__init__.cpython-312.pyc b/splasher/ui/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..888905e72c010e948845d39c3fba7118143abcdd Binary files /dev/null and b/splasher/ui/__pycache__/__init__.cpython-312.pyc differ diff --git a/splasher/ui/__pycache__/channel_manager.cpython-312.pyc b/splasher/ui/__pycache__/channel_manager.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d667c9efb9c957126e6f4ad14b9941ee493a903b Binary files /dev/null and b/splasher/ui/__pycache__/channel_manager.cpython-312.pyc differ diff --git a/splasher/ui/__pycache__/cloud_view.cpython-312.pyc b/splasher/ui/__pycache__/cloud_view.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..828762526b0e0bbf28fd1bf21144f83ad415c6f3 Binary files /dev/null and b/splasher/ui/__pycache__/cloud_view.cpython-312.pyc differ diff --git a/splasher/ui/__pycache__/grid_designer.cpython-312.pyc b/splasher/ui/__pycache__/grid_designer.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9e721df334a950cc595f68011eed6422e139542c Binary files /dev/null and b/splasher/ui/__pycache__/grid_designer.cpython-312.pyc differ diff --git a/splasher/ui/__pycache__/grid_view.cpython-312.pyc b/splasher/ui/__pycache__/grid_view.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f0013f24c9fbea8fbda7a2638ebfcb1bd825f902 Binary files /dev/null and b/splasher/ui/__pycache__/grid_view.cpython-312.pyc differ diff --git a/splasher/ui/__pycache__/image_view.cpython-312.pyc b/splasher/ui/__pycache__/image_view.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a855533a2a3df785c4db339d5768ba0543c9ec54 Binary files /dev/null and b/splasher/ui/__pycache__/image_view.cpython-312.pyc differ diff --git a/splasher/ui/__pycache__/main_window.cpython-312.pyc b/splasher/ui/__pycache__/main_window.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..bb9b8494c71b2db60651f8320da28d591ea749aa Binary files /dev/null and b/splasher/ui/__pycache__/main_window.cpython-312.pyc differ diff --git a/splasher/ui/__pycache__/palette.cpython-312.pyc b/splasher/ui/__pycache__/palette.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..87bfe905407864208e769bc425c6fd680b5e0e72 Binary files /dev/null and b/splasher/ui/__pycache__/palette.cpython-312.pyc differ diff --git a/splasher/ui/__pycache__/timeline.cpython-312.pyc b/splasher/ui/__pycache__/timeline.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a49ffe9e5b2c067d4649064f75f86230f72638e0 Binary files /dev/null and b/splasher/ui/__pycache__/timeline.cpython-312.pyc differ diff --git a/splasher/ui/app.py b/splasher/ui/app.py new file mode 100644 index 0000000000000000000000000000000000000000..4e46e1642411c443ccd1d658c406acc69227f117 --- /dev/null +++ b/splasher/ui/app.py @@ -0,0 +1,33 @@ +"""Point d'entrée UI : `launch(source)`.""" + +from __future__ import annotations + +import sys + +import pyqtgraph as pg +from PySide6 import QtWidgets + +from ..core.source import Source +from .main_window import MainWindow + + +def launch(source: Source, *, labels=None, title: str = "Splasher", block: bool = True): + """Ouvre la fenêtre Splasher sur `source`. + + `labels` : un `LabelSet` optionnel (défaut = `LabelSet.default()`). + Réutilise une `QApplication` existante si présente. Si `block` et qu'aucune + boucle n'est active, lance `app.exec()`. Renvoie la fenêtre. + """ + pg.setConfigOptions(imageAxisOrder="row-major", antialias=False) + + app = QtWidgets.QApplication.instance() + owns_app = app is None + if owns_app: + app = QtWidgets.QApplication(sys.argv[:1]) + + window = MainWindow(source, title=title, labelset=labels) + window.show() + + if block and owns_app: + app.exec() + return window diff --git a/splasher/ui/channel_manager.py b/splasher/ui/channel_manager.py new file mode 100644 index 0000000000000000000000000000000000000000..6e2ab181ed394342dd70eccbdd52b662b27c3b0e --- /dev/null +++ b/splasher/ui/channel_manager.py @@ -0,0 +1,55 @@ +"""Gestionnaire de canaux : afficher/masquer les canaux disponibles de la `Source`. + +Liste tous les canaux (nuages, caméras, poses…). Les nuages et les images sont +cochables (afficher/masquer la vue correspondante). Les poses/scalaires sont listés +pour information (non cochables). +""" + +from __future__ import annotations + +from PySide6 import QtCore, QtWidgets + +from ..core.source import ChannelKind, ChannelSpec + +_KIND_LABEL = { + ChannelKind.POINTCLOUD: "nuage", + ChannelKind.IMAGE: "caméra", + ChannelKind.POSE: "pose", + ChannelKind.SCALAR: "scalaire", +} + + +class ChannelManager(QtWidgets.QWidget): + visibilityChanged = QtCore.Signal() + + def __init__(self, specs: list[ChannelSpec]) -> None: + super().__init__() + self._boxes: dict[str, QtWidgets.QCheckBox] = {} + self._kind: dict[str, ChannelKind] = {} + + layout = QtWidgets.QVBoxLayout(self) + layout.setContentsMargins(6, 4, 6, 4) + layout.setSpacing(2) + + for spec in specs: + self._kind[spec.name] = spec.kind + toggleable = spec.kind in (ChannelKind.POINTCLOUD, ChannelKind.IMAGE) + row = QtWidgets.QCheckBox(f"{spec.name} · {_KIND_LABEL.get(spec.kind, '?')}") + row.setChecked(toggleable) + row.setEnabled(toggleable) + if toggleable: + row.toggled.connect(lambda _checked: self.visibilityChanged.emit()) + self._boxes[spec.name] = row + layout.addWidget(row) + + layout.addStretch(1) + + def _visible_of_kind(self, kind: ChannelKind) -> set[str]: + return {name for name, box in self._boxes.items() + if self._kind[name] == kind and box.isChecked()} + + def visible_clouds(self) -> set[str]: + return self._visible_of_kind(ChannelKind.POINTCLOUD) + + def visible_images(self) -> set[str]: + return self._visible_of_kind(ChannelKind.IMAGE) diff --git a/splasher/ui/cloud_view.py b/splasher/ui/cloud_view.py new file mode 100644 index 0000000000000000000000000000000000000000..d5bff07de6ae0b7cbdbd5768c464d9b9ad7a0aaf --- /dev/null +++ b/splasher/ui/cloud_view.py @@ -0,0 +1,52 @@ +"""Vue 3D du/des nuage(s) de points : navigation libre (orbit/pan/zoom).""" + +from __future__ import annotations + +import numpy as np +import pyqtgraph.opengl as gl + +from ..core.colormap import colormap + + +class CloudView(gl.GLViewWidget): + """Affiche un ou plusieurs nuages nommés. Couleur par hauteur (z) par défaut.""" + + def __init__(self) -> None: + super().__init__() + self.setBackgroundColor("#101014") + self.setCameraPosition(distance=60.0, elevation=30.0, azimuth=-60.0) + + grid = gl.GLGridItem() + grid.setSize(x=100, y=100) + grid.setSpacing(x=5, y=5) + grid.setColor((255, 255, 255, 40)) + self.addItem(grid) + self._grid = grid + + self._scatters: dict[str, gl.GLScatterPlotItem] = {} + + def set_cloud(self, name: str, points: np.ndarray, colors: np.ndarray | None = None, + size: float = 2.0) -> None: + """Met à jour (ou crée) le nuage `name`. `points` (N, 3+), `colors` (N, 4) RGBA [0,1].""" + if points is None or len(points) == 0: + if name in self._scatters: + self._scatters[name].setData(pos=np.zeros((0, 3), np.float32)) + return + + xyz = np.ascontiguousarray(points[:, :3], dtype=np.float32) + if colors is None: + colors = colormap(xyz[:, 2]) + + item = self._scatters.get(name) + if item is None: + item = gl.GLScatterPlotItem(pxMode=True) + # 'opaque' (et non l'additif par défaut) : les couleurs ne saturent pas + # vers le blanc quand des dizaines de milliers de points se superposent (cumul). + item.setGLOptions("opaque") + self.addItem(item) + self._scatters[name] = item + item.setData(pos=xyz, color=colors, size=size) + + def clear_clouds(self) -> None: + for item in self._scatters.values(): + item.setData(pos=np.zeros((0, 3), np.float32)) diff --git a/splasher/ui/grid_designer.py b/splasher/ui/grid_designer.py new file mode 100644 index 0000000000000000000000000000000000000000..232c740cc4c1c54f477c7be6f5b16894bbbbfd6c --- /dev/null +++ b/splasher/ui/grid_designer.py @@ -0,0 +1,103 @@ +"""Panneau de conception de la grille : étendue monde + taille de carré. + +Deux temps, pour que ce soit clair : +- éditer les champs met à jour l'**aperçu** des lignes (`previewChanged`) et l'affichage + `cols × rows`, sans rien effacer ; +- le bouton **« Nouvelle grille »** crée/applique la grille (`newGridRequested`), ce qui + **réinitialise** les rasters de grille (ils sont liés à la géométrie). +""" + +from __future__ import annotations + +from PySide6 import QtCore, QtWidgets + +from ..core.grid import Grid + + +def _spin(value, lo, hi, step, decimals) -> QtWidgets.QDoubleSpinBox: + sb = QtWidgets.QDoubleSpinBox() + sb.setRange(lo, hi) + sb.setSingleStep(step) + sb.setDecimals(decimals) + sb.setValue(value) + return sb + + +class GridDesigner(QtWidgets.QWidget): + previewChanged = QtCore.Signal(object) # Grid (aperçu, n'efface rien) + newGridRequested = QtCore.Signal(object) # Grid (création/commit) + + def __init__(self, grid: Grid) -> None: + super().__init__() + self._committed = grid + + self._xmin = _spin(grid.xmin, -2000, 2000, 1.0, 1) + self._xmax = _spin(grid.xmax, -2000, 2000, 1.0, 1) + self._ymin = _spin(grid.ymin, -2000, 2000, 1.0, 1) + self._ymax = _spin(grid.ymax, -2000, 2000, 1.0, 1) + self._cell = _spin(grid.cell_size, 0.05, 100.0, 0.5, 2) + + form = QtWidgets.QFormLayout() + form.addRow("x min", self._xmin) + form.addRow("x max", self._xmax) + form.addRow("y min", self._ymin) + form.addRow("y max", self._ymax) + form.addRow("taille carré (m)", self._cell) + + self._dims = QtWidgets.QLabel() + self._dims.setStyleSheet("color:#9cf; font-weight:bold;") + + self._btn = QtWidgets.QPushButton("➕ Nouvelle grille") + self._btn.setToolTip("Créer/appliquer la grille à cette taille (réinitialise la grille labélisée)") + self._btn.clicked.connect(self._on_commit) + + root = QtWidgets.QVBoxLayout(self) + root.addLayout(form) + root.addWidget(self._dims) + root.addWidget(self._btn) + root.addStretch(1) + + for sb in (self._xmin, self._xmax, self._ymin, self._ymax, self._cell): + sb.valueChanged.connect(self._on_edit) + + self._update_dims(grid) + + def committed_grid(self) -> Grid: + return self._committed + + def set_grid(self, grid: Grid) -> None: + """Recale les champs sur `grid` sans rien émettre (ex. après chargement).""" + self._committed = grid + for sb, val in ((self._xmin, grid.xmin), (self._xmax, grid.xmax), + (self._ymin, grid.ymin), (self._ymax, grid.ymax), (self._cell, grid.cell_size)): + sb.blockSignals(True) + sb.setValue(val) + sb.blockSignals(False) + self._update_dims(grid) + + def _build(self) -> Grid | None: + try: + return Grid(self._xmin.value(), self._xmax.value(), + self._ymin.value(), self._ymax.value(), self._cell.value()) + except ValueError: + return None + + def _update_dims(self, grid: Grid) -> None: + self._dims.setText(f"{grid.cols} × {grid.rows} carrés ({grid.cols * grid.rows} cellules)") + + def _on_edit(self) -> None: + grid = self._build() + if grid is None: + self._dims.setText("étendue invalide") + self._btn.setEnabled(False) + return + self._btn.setEnabled(True) + self._update_dims(grid) + self.previewChanged.emit(grid) + + def _on_commit(self) -> None: + grid = self._build() + if grid is None: + return + self._committed = grid + self.newGridRequested.emit(grid) diff --git a/splasher/ui/grid_view.py b/splasher/ui/grid_view.py new file mode 100644 index 0000000000000000000000000000000000000000..637ded1b1f10e97c0127d47c9ffcbac78b82d221 --- /dev/null +++ b/splasher/ui/grid_view.py @@ -0,0 +1,124 @@ +"""Vue de dessus (BEV) : grille de carrés, sous-couche densité, raster de labels, +et sélection par rectangle (rubber-band) pour peindre. +""" + +from __future__ import annotations + +import numpy as np +import pyqtgraph as pg +from PySide6 import QtCore, QtWidgets + +from ..core.grid import Grid + + +class _PaintViewBox(pg.ViewBox): + """ViewBox avec un mode « peindre » : clic-glisser gauche dessine un rectangle.""" + + rectDrawn = QtCore.Signal(object) # (x0, y0, x1, y1) en coords monde + + def __init__(self, **kwargs) -> None: + super().__init__(**kwargs) + self._paint = True + self._preview = QtWidgets.QGraphicsRectItem() + self._preview.setPen(pg.mkPen(255, 213, 74, width=1)) + self._preview.setBrush(pg.mkBrush(255, 213, 74, 50)) + self._preview.setZValue(50) + self._preview.hide() + self.addItem(self._preview, ignoreBounds=True) + + def set_paint_mode(self, on: bool) -> None: + self._paint = on + + def mouseDragEvent(self, ev, axis=None): + if self._paint and ev.button() == QtCore.Qt.LeftButton: + ev.accept() + p0 = self.mapSceneToView(ev.buttonDownScenePos()) + p1 = self.mapSceneToView(ev.scenePos()) + x0, y0, x1, y1 = p0.x(), p0.y(), p1.x(), p1.y() + self._preview.setRect(min(x0, x1), min(y0, y1), abs(x1 - x0), abs(y1 - y0)) + self._preview.show() + if ev.isFinish(): + self._preview.hide() + self.rectDrawn.emit((x0, y0, x1, y1)) + else: + super().mouseDragEvent(ev, axis) + + +class GridView(QtWidgets.QWidget): + rectDrawn = QtCore.Signal(object) + + def __init__(self) -> None: + super().__init__() + layout = QtWidgets.QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + + self._glw = pg.GraphicsLayoutWidget() + layout.addWidget(self._glw) + self._vb = _PaintViewBox() + self._vb.setAspectLocked(True) # carrés vraiment carrés, y vers le haut + self._vb.setBackgroundColor("#0c0c10") + self._glw.addItem(self._vb) + self._vb.rectDrawn.connect(self.rectDrawn) + + self._under = pg.ImageItem() # densité/hauteur BEV (M2) + self._under.setZValue(-10) + self._labels = pg.ImageItem() # raster de labels colorisé (M3) + self._labels.setZValue(-5) + self._sel = pg.ImageItem() # surbrillance de la sélection (mode select) + self._sel.setZValue(8) + self._pts = pg.ScatterPlotItem( # points top-down (référence) + size=2, pen=None, brush=pg.mkBrush(170, 175, 205, 70), pxMode=True + ) + self._grid_lines = pg.PlotCurveItem(pen=pg.mkPen(120, 125, 150, 140, width=1)) + self._grid_lines.setZValue(5) + for item in (self._under, self._labels, self._pts, self._grid_lines, self._sel): + self._vb.addItem(item) + + self._grid: Grid | None = None + + # ------------------------------------------------------------------ API + def set_paint_mode(self, on: bool) -> None: + self._vb.set_paint_mode(on) + + def set_grid(self, grid: Grid, autorange: bool = True) -> None: + self._grid = grid + xs, ys = grid.line_segments() + self._grid_lines.setData(xs, ys, connect="pairs") + if autorange: + self._vb.setRange( + xRange=(grid.xmin, grid.xmin + grid.width), + yRange=(grid.ymin, grid.ymin + grid.height), + padding=0.03, + ) + + def set_topdown_points(self, xy: np.ndarray | None, max_points: int = 12000) -> None: + if xy is None or len(xy) == 0: + self._pts.setData(x=[], y=[]) + return + xy = np.asarray(xy) + if len(xy) > max_points: + xy = xy[np.linspace(0, len(xy) - 1, max_points).astype(int)] + self._pts.setData(x=xy[:, 0], y=xy[:, 1]) + + def set_underlay(self, image: np.ndarray | None, grid: Grid) -> None: + self._set_raster(self._under, image, grid, levels=(0, 255)) + + def set_labels(self, image: np.ndarray | None, grid: Grid) -> None: + self._set_raster(self._labels, image, grid, levels=(0, 255)) + + def set_selection(self, mask: np.ndarray | None, grid: Grid) -> None: + """Surligne les cellules sélectionnées (`mask` booléen `(rows, cols)`).""" + if mask is None or not mask.any(): + self._sel.clear() + return + rgba = np.zeros((grid.rows, grid.cols, 4), dtype=np.uint8) + rgba[mask] = (90, 200, 255, 110) # cyan translucide + self._set_raster(self._sel, rgba, grid, levels=(0, 255)) + + def _set_raster(self, item: pg.ImageItem, image, grid: Grid, levels) -> None: + if image is None: + item.clear() + return + item.setImage(image, autoLevels=False, levels=levels) + x, y, w, h = grid.image_rect() + item.setRect(QtCore.QRectF(x, y, w, h)) diff --git a/splasher/ui/image_view.py b/splasher/ui/image_view.py new file mode 100644 index 0000000000000000000000000000000000000000..910696800461ffbad191358dd83b3521c8809739 --- /dev/null +++ b/splasher/ui/image_view.py @@ -0,0 +1,36 @@ +"""Panneau d'affichage d'un canal image (caméra).""" + +from __future__ import annotations + +import numpy as np +import pyqtgraph as pg +from PySide6 import QtWidgets + + +class ImageView(QtWidgets.QWidget): + """ViewBox verrouillé en aspect avec un `ImageItem`. Orientation image naturelle.""" + + def __init__(self, title: str = "") -> None: + super().__init__() + layout = QtWidgets.QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(2) + + if title: + label = QtWidgets.QLabel(title) + label.setStyleSheet("color:#ccc; padding:2px;") + layout.addWidget(label) + + self._glw = pg.GraphicsLayoutWidget() + layout.addWidget(self._glw) + self._vb = self._glw.addViewBox() + self._vb.setAspectLocked(True) + self._vb.invertY(True) # (row 0) en haut, image à l'endroit + self._img = pg.ImageItem() + self._vb.addItem(self._img) + + def set_image(self, image: np.ndarray) -> None: + if image is None: + return + self._img.setImage(np.ascontiguousarray(image), autoLevels=True) + self._vb.autoRange(padding=0) diff --git a/splasher/ui/main_window.py b/splasher/ui/main_window.py new file mode 100644 index 0000000000000000000000000000000000000000..26aa570348ec4e7ffeb3d5ae8c2d2263d41cb348 --- /dev/null +++ b/splasher/ui/main_window.py @@ -0,0 +1,437 @@ +"""Fenêtre principale. + +Disposition : nuage 3D (navigation libre) | vue de dessus (grille BEV). +Docks : conception de grille + palette + canaux (gauche), caméras (droite), temps (bas). + +- La grille se crée explicitement (bouton « Nouvelle grille ») ; l'undo est par frame. +- Les canaux disponibles s'affichent/se masquent via le gestionnaire de canaux. +- La sélection d'un rectangle nourrit les cibles actives (Grille / Points) ; en mode + cumulé, les labels points sont décumulés vers chaque frame source. +""" + +from __future__ import annotations + +import numpy as np +from PySide6 import QtCore, QtWidgets + +from ..core.accumulate import Accumulation, accumulate, window_indices +from ..core.colormap import colormap +from ..core.grid import Grid, grid_from_points +from ..core.io import load_session, save_session +from ..core.labels import LabelSet +from ..core.projection import bev_image, bev_max_height, cells_in_rect, points_in_rect +from ..core.source import ChannelKind, Source, channels_of_kind +from ..core.target import GridTarget, PointTarget +from .channel_manager import ChannelManager +from .cloud_view import CloudView +from .grid_designer import GridDesigner +from .grid_view import GridView +from .image_view import ImageView +from .palette import Palette +from .timeline import Timeline + + +class MainWindow(QtWidgets.QMainWindow): + def __init__(self, source: Source, title: str = "Splasher", + labelset: LabelSet | None = None) -> None: + super().__init__() + self.setWindowTitle(title) + self.resize(1480, 860) + self._source = source + self._index = 0 + self._labelset = labelset or LabelSet.default() + + self._cloud_keys = channels_of_kind(source, ChannelKind.POINTCLOUD) + self._image_keys = channels_of_kind(source, ChannelKind.IMAGE) + pose_keys = channels_of_kind(source, ChannelKind.POSE) + self._pose_key = pose_keys[0] if pose_keys else None + self._accum_radius = 0 + self._visible_clouds: set[str] = set(self._cloud_keys) + self._visible_images: set[str] = set(self._image_keys) + self._tool = "paint" # "paint" (peinture directe) | "select" (sélection marquee) + self._selection: np.ndarray | None = None # masque (rows, cols) ou None + + # --- canvases centraux : 3D | vue de dessus ------------------------- + self._cloud_view = CloudView() + self._grid_view = GridView() + self._grid_view.rectDrawn.connect(self._on_rect) + central = QtWidgets.QSplitter(QtCore.Qt.Horizontal) + central.addWidget(self._cloud_view) + central.addWidget(self._grid_view) + central.setStretchFactor(0, 1) + central.setStretchFactor(1, 1) + self.setCentralWidget(central) + + # --- grille + cibles ------------------------------------------------ + self._grid = self._default_grid() + self._grid_view.set_grid(self._grid) + self._grid_target = GridTarget(self._grid, ignore_id=self._labelset.ignore_id) + self._point_target = PointTarget(ignore_id=self._labelset.ignore_id) + self._active_targets: set[str] = {"grid"} + + # --- docks ---------------------------------------------------------- + self._designer = GridDesigner(self._grid) + self._designer.previewChanged.connect(self._on_grid_preview) + self._designer.newGridRequested.connect(self._on_grid_commit) + self._add_dock("Grille", self._designer, QtCore.Qt.LeftDockWidgetArea) + + self._palette = Palette(self._labelset) + self._palette.classChanged.connect(lambda _id: self._update_status()) + self._add_dock("Classes", self._palette, QtCore.Qt.LeftDockWidgetArea) + + self._channels = ChannelManager(source.channels()) + self._channels.visibilityChanged.connect(self._on_visibility) + self._add_dock("Canaux", self._channels, QtCore.Qt.LeftDockWidgetArea) + + self._build_toolbar() + + self._image_views: dict[str, ImageView] = {} + if self._image_keys: + self._image_panel = QtWidgets.QWidget() + v = QtWidgets.QVBoxLayout(self._image_panel) + v.setContentsMargins(0, 0, 0, 0) + for key in self._image_keys: + iv = ImageView(title=key) + self._image_views[key] = iv + v.addWidget(iv) + scroll = QtWidgets.QScrollArea() + scroll.setWidgetResizable(True) + scroll.setWidget(self._image_panel) + self._add_dock("Caméras", scroll, QtCore.Qt.RightDockWidgetArea) + + self._timeline = Timeline(len(source)) + self._timeline.frameChanged.connect(self._show_frame) + self._add_dock("Temps", self._timeline, QtCore.Qt.BottomDockWidgetArea, fixed=True) + + self._update_status() + if len(source) > 0: + self._show_frame(0) + + # ------------------------------------------------------------------ build + def _add_dock(self, title, widget, area, fixed: bool = False) -> None: + dock = QtWidgets.QDockWidget(title, self) + dock.setWidget(widget) + if fixed: + dock.setFeatures(QtWidgets.QDockWidget.NoDockWidgetFeatures) + self.addDockWidget(area, dock) + + def _tool_button(self, tb, text, *, checkable=False, checked=False, on=None, tip=""): + btn = QtWidgets.QToolButton() + btn.setText(text) + btn.setCheckable(checkable) + btn.setChecked(checked) + if tip: + btn.setToolTip(tip) + if on is not None: + (btn.toggled if checkable else btn.clicked).connect(on) + tb.addWidget(btn) + return btn + + def _build_toolbar(self) -> None: + tb = self.addToolBar("Outils") + tb.setMovable(False) + self._tool_button(tb, "✏ Peindre", checkable=True, checked=True, + on=self._grid_view.set_paint_mode, + tip="Peindre (clic-glisser) vs naviguer (décoché)") + self._tool_button(tb, "Effacer frame", on=self._on_clear) + self._tool_button(tb, "↶ Annuler (frame)", on=self._on_undo) + tb.addSeparator() + self._tool_button(tb, "⬚ Sélection", checkable=True, checked=False, + on=self._on_tool_changed, + tip="Sélectionner des cellules (Shift = ajouter), puis appliquer.\n" + "Décoché = peinture directe.") + self._apply_sel_btn = self._tool_button(tb, "✓ Appliquer sél.", on=self._on_apply_selection, + tip="Appliquer la classe active à la sélection") + self._clear_sel_btn = self._tool_button(tb, "✗ Vider sél.", on=self._on_clear_selection) + self._apply_sel_btn.setEnabled(False) + self._clear_sel_btn.setEnabled(False) + tb.addSeparator() + lbl = QtWidgets.QLabel(" cible : ") + lbl.setStyleSheet("color:#aaa;") + tb.addWidget(lbl) + self._tool_button(tb, "▦ Grille", checkable=True, checked=True, + on=lambda on: self._toggle_target("grid", on), + tip="Sortie : raster de la grille BEV") + self._tool_button(tb, "• Points", checkable=True, checked=False, + on=lambda on: self._toggle_target("points", on), + tip="Sortie : labels par point (le nuage se colorise)") + tb.addSeparator() + lbl2 = QtWidgets.QLabel(" cumul ± ") + lbl2.setStyleSheet("color:#aaa;") + tb.addWidget(lbl2) + self._accum_spin = QtWidgets.QSpinBox() + self._accum_spin.setRange(0, max(0, len(self._source) - 1)) + self._accum_spin.setSuffix(" frames") + self._accum_spin.setToolTip( + "Cumuler ±N frames recalées par leurs poses dans le repère du frame courant.\n" + "La grille et les labels restent par frame (décumul)." + ) + self._accum_spin.setEnabled(self._pose_key is not None and len(self._source) > 1) + self._accum_spin.valueChanged.connect(self._on_accum_changed) + tb.addWidget(self._accum_spin) + tb.addSeparator() + self._tool_button(tb, "💾 Enregistrer", on=self._on_save, + tip="Exporter labels (.npy/.png/.json)") + self._tool_button(tb, "📂 Charger", on=self._on_load, + tip="Recharger une session de labels") + + # ------------------------------------------------------------------ utils + def _frame_points(self, frame) -> np.ndarray: + parts = [frame.channels[k] for k in self._cloud_keys + if frame.channels.get(k) is not None and len(frame.channels[k])] + return np.concatenate(parts, axis=0) if parts else np.zeros((0, 3), np.float32) + + def _default_grid(self) -> Grid: + if len(self._source) == 0 or not self._cloud_keys: + return Grid(-20.0, 20.0, -20.0, 20.0, 1.0) + return grid_from_points(self._frame_points(self._source[0])[:, :2], cell_size=1.0) + + def _visible_cloud_indices(self) -> list[int]: + return [i for i, k in enumerate(self._cloud_keys) if k in self._visible_clouds] + + def _update_status(self) -> None: + active = ", ".join(sorted(self._active_targets)) or "—" + cls = self._labelset.name_of(self._palette.active_id()) + cumul = f"±{self._accum_radius}" if self._accum_radius else "off" + outil = "sélection" if self._tool == "select" else "peinture" + self.statusBar().showMessage( + f"{len(self._source)} frames · nuages: {sorted(self._visible_clouds) or '—'} · " + f"caméras: {sorted(self._visible_images) or '—'} · classe: {cls} · " + f"cible: {active} · cumul: {cumul} · outil: {outil}" + ) + + # --------------------------------------------------------------- handlers + def _toggle_target(self, name: str, on: bool) -> None: + (self._active_targets.add if on else self._active_targets.discard)(name) + self._update_status() + + def _on_visibility(self) -> None: + self._visible_clouds = self._channels.visible_clouds() + self._visible_images = self._channels.visible_images() + for key, view in self._image_views.items(): + view.setVisible(key in self._visible_images) + acc = self._accumulated() + self._refresh_cloud(acc) + self._refresh_bev(acc) + self._grid_view.set_topdown_points(self._visible_xy(acc)) + self._update_status() + + def _on_grid_preview(self, grid: Grid) -> None: + # aperçu des lignes seulement (n'efface rien, ne change pas la grille active) + self._grid_view.set_grid(grid, autorange=False) + + def _on_grid_commit(self, grid: Grid) -> None: + n_labelled = len(self._grid_target.rasters()) + if n_labelled: + reply = QtWidgets.QMessageBox.warning( + self, "Nouvelle grille", + f"Une labélisation de grille existe déjà ({n_labelled} frame(s)).\n" + "Créer une nouvelle grille l'effacera (les labels par point sont conservés).\n\n" + "Continuer ?", + QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No, + QtWidgets.QMessageBox.No, + ) + if reply != QtWidgets.QMessageBox.Yes: + # annulation : on restaure l'affichage sur la grille active + self._designer.set_grid(self._grid) + self._grid_view.set_grid(self._grid, autorange=False) + return + self._grid = grid + self._grid_view.set_grid(grid, autorange=True) + self._grid_target = GridTarget(grid, ignore_id=self._labelset.ignore_id) + self._set_selection(None) + self._refresh_bev() + self._refresh_labels() + msg = f"nouvelle grille {grid.cols}×{grid.rows}" + if n_labelled: + msg += " · grille labélisée réinitialisée" + self.statusBar().showMessage(msg, 6000) + + def _on_accum_changed(self, value: int) -> None: + self._accum_radius = int(value) + acc = self._accumulated() + self._refresh_cloud(acc) + self._refresh_bev(acc) + self._grid_view.set_topdown_points(self._visible_xy(acc)) + self._update_status() + + def _on_tool_changed(self, on: bool) -> None: + self._tool = "select" if on else "paint" + if not on: + self._set_selection(None) + self._update_status() + + def _set_selection(self, mask: np.ndarray | None) -> None: + self._selection = mask + self._grid_view.set_selection(mask, self._grid) + active = mask is not None and bool(mask.any()) + self._apply_sel_btn.setEnabled(active) + self._clear_sel_btn.setEnabled(active) + + def _on_clear_selection(self) -> None: + self._set_selection(None) + + def _on_rect(self, rect) -> None: + if self._tool == "select": + self._select_rect(rect) + return + cls = self._palette.active_id() + changed = False + if "grid" in self._active_targets: + changed |= self._grid_target.apply(self._index, rect, cls) + if "points" in self._active_targets: + acc = self._accumulated() + changed |= self._paint_points(acc, points_in_rect(acc.xy, rect), cls) + if changed: + self._refresh_labels() + self._refresh_cloud() + + def _select_rect(self, rect) -> None: + si, sj = cells_in_rect(rect, self._grid) + if si.start >= si.stop or sj.start >= sj.stop: + return + rect_mask = np.zeros(self._grid.shape, dtype=bool) + rect_mask[si, sj] = True + additive = bool(QtWidgets.QApplication.keyboardModifiers() & QtCore.Qt.ShiftModifier) + if additive and self._selection is not None: + self._set_selection(self._selection | rect_mask) + else: + self._set_selection(rect_mask) + + def _paint_points(self, acc: Accumulation, point_mask: np.ndarray, cls: int) -> bool: + """Décumule un masque de points (du cumul `acc`) vers chaque frame source.""" + mask = point_mask & acc.visible_mask(self._visible_cloud_indices()) + if not mask.any(): + return False + fids, pids = acc.frame_id[mask], acc.point_id[mask] + frame_to_sel = {int(f): (pids[fids == f], acc.counts[int(f)]) for f in np.unique(fids)} + return self._point_target.apply_scatter(self._index, frame_to_sel, cls) + + def _on_apply_selection(self) -> None: + if self._selection is None or not self._selection.any(): + return + cls = self._palette.active_id() + changed = False + if "grid" in self._active_targets: + changed |= self._grid_target.apply_mask(self._index, self._selection, cls) + if "points" in self._active_targets: + acc = self._accumulated() + ij, valid = self._grid.world_to_cell(acc.xy) + hit = np.zeros(len(acc.xy), dtype=bool) + hit[valid] = self._selection[ij[valid, 0], ij[valid, 1]] + changed |= self._paint_points(acc, hit, cls) + if changed: + self._set_selection(None) + self._refresh_labels() + self._refresh_cloud() + + def _on_clear(self) -> None: + if "grid" in self._active_targets: + self._grid_target.clear(self._index) + if "points" in self._active_targets: + self._point_target.clear(self._index) + self._refresh_labels() + self._refresh_cloud() + + def _on_undo(self) -> None: + if "grid" in self._active_targets: + self._grid_target.undo(self._index) + if "points" in self._active_targets: + self._point_target.undo(self._index) + self._refresh_labels() + self._refresh_cloud() + + def _on_save(self) -> None: + d = QtWidgets.QFileDialog.getExistingDirectory(self, "Dossier de sortie des labels") + if not d: + return + save_session(d, grid=self._grid, labelset=self._labelset, + grid_target=self._grid_target, point_target=self._point_target) + self.statusBar().showMessage( + f"enregistré dans {d} · grille: {len(self._grid_target.rasters())} frame(s) · " + f"points: {len(self._point_target.all_labels())} frame(s)", 8000) + + def _on_load(self) -> None: + d = QtWidgets.QFileDialog.getExistingDirectory(self, "Charger une session de labels") + if not d: + return + data = load_session(d) + self._grid = data["grid"] + self._designer.set_grid(self._grid) + self._grid_view.set_grid(self._grid, autorange=False) + self._grid_target = GridTarget(self._grid, ignore_id=self._labelset.ignore_id) + self._grid_target.load_rasters(data["grid_labels"]) + self._point_target = PointTarget(ignore_id=self._labelset.ignore_id) + self._point_target.load_labels(data["point_labels"]) + self._set_selection(None) + self._refresh_bev() + self._refresh_labels() + self._refresh_cloud() + self.statusBar().showMessage(f"chargé depuis {d}", 8000) + + # ------------------------------------------------------------- rendering + def _accumulated(self) -> Accumulation: + """Cumul sur **tous** les canaux nuage (point_id stable) ; cumul ±radius via poses.""" + n = len(self._source) + if self._accum_radius > 0 and self._pose_key is not None: + idx = window_indices(self._index, self._accum_radius, n) + return accumulate(self._source, self._index, idx, self._cloud_keys, self._pose_key) + return accumulate(self._source, self._index, [self._index], self._cloud_keys, self._pose_key) + + def _visible_xy(self, acc: Accumulation) -> np.ndarray: + vis = acc.visible_mask(self._visible_cloud_indices()) + return acc.xy[vis] + + def _acc_colors(self, acc: Accumulation) -> np.ndarray | None: + if len(acc.points) == 0: + return None + colors = colormap(acc.points[:, 2]) + lab = np.full(len(acc.points), self._labelset.ignore_id, np.int64) + for f in np.unique(acc.frame_id): + f = int(f) + if not self._point_target.has(f): + continue + src = self._point_target.labels(f) + sel = acc.frame_id == f + pid = acc.point_id[sel] + if len(pid) and int(pid.max()) < len(src): + lab[sel] = src[pid] + mask = lab != self._labelset.ignore_id + if mask.any(): + lut = self._labelset.lut(alpha=255, max_id=int(lab.max())) + colors[mask] = lut[lab[mask]].astype(np.float32) / 255.0 + return colors + + def _refresh_cloud(self, acc: Accumulation | None = None) -> None: + acc = acc if acc is not None else self._accumulated() + vis = acc.visible_mask(self._visible_cloud_indices()) + colors = self._acc_colors(acc) + self._cloud_view.set_cloud( + "scene", acc.points[vis], None if colors is None else colors[vis] + ) + + def _refresh_bev(self, acc: Accumulation | None = None) -> None: + acc = acc if acc is not None else self._accumulated() + vis = acc.visible_mask(self._visible_cloud_indices()) + height = bev_max_height(acc.points[vis], self._grid) + self._grid_view.set_underlay(bev_image(height), self._grid) + + def _refresh_labels(self) -> None: + if self._grid_target.has(self._index): + raster = self._grid_target.raster(self._index) + self._grid_view.set_labels(self._labelset.colorize(raster, alpha=170), self._grid) + else: + self._grid_view.set_labels(None, self._grid) + + def _show_frame(self, i: int) -> None: + self._index = i + frame = self._source[i] + acc = self._accumulated() + self._refresh_cloud(acc) + self._refresh_bev(acc) + self._grid_view.set_topdown_points(self._visible_xy(acc)) + for key in self._image_keys: + img = frame.channels.get(key) + if img is not None: + self._image_views[key].set_image(img) + self._refresh_labels() diff --git a/splasher/ui/palette.py b/splasher/ui/palette.py new file mode 100644 index 0000000000000000000000000000000000000000..319d6292761cea4b5b55f6675c3929ad02f771d0 --- /dev/null +++ b/splasher/ui/palette.py @@ -0,0 +1,45 @@ +"""Palette de classes : sélection de la classe active (pastille couleur + nom).""" + +from __future__ import annotations + +from PySide6 import QtCore, QtGui, QtWidgets + +from ..core.labels import LabelSet + + +def _swatch(color) -> QtGui.QIcon: + pix = QtGui.QPixmap(16, 16) + pix.fill(QtGui.QColor(*color)) + return QtGui.QIcon(pix) + + +class Palette(QtWidgets.QWidget): + classChanged = QtCore.Signal(int) # id de classe active + + def __init__(self, labelset: LabelSet) -> None: + super().__init__() + self._labelset = labelset + + self._list = QtWidgets.QListWidget() + for c in labelset.paintable: + item = QtWidgets.QListWidgetItem(_swatch(c.color), f"{c.id} · {c.name}") + item.setData(QtCore.Qt.UserRole, c.id) + self._list.addItem(item) + self._list.currentItemChanged.connect(self._on_change) + + layout = QtWidgets.QVBoxLayout(self) + layout.setContentsMargins(2, 2, 2, 2) + layout.addWidget(self._list) + + if self._list.count(): + self._list.setCurrentRow(0) + + def active_id(self) -> int: + item = self._list.currentItem() + if item is None: + return self._labelset.ignore_id + return int(item.data(QtCore.Qt.UserRole)) + + def _on_change(self, current, _previous) -> None: + if current is not None: + self.classChanged.emit(int(current.data(QtCore.Qt.UserRole))) diff --git a/splasher/ui/timeline.py b/splasher/ui/timeline.py new file mode 100644 index 0000000000000000000000000000000000000000..aea0a703cde9c4f07792712e174bea64868be643 --- /dev/null +++ b/splasher/ui/timeline.py @@ -0,0 +1,54 @@ +"""Curseur temporel : slider sur les frames + lecture (play/pause).""" + +from __future__ import annotations + +from PySide6 import QtCore, QtWidgets + + +class Timeline(QtWidgets.QWidget): + frameChanged = QtCore.Signal(int) + + def __init__(self, n_frames: int) -> None: + super().__init__() + self._n = max(1, n_frames) + + self._play_btn = QtWidgets.QToolButton() + self._play_btn.setText("▶") + self._play_btn.setCheckable(True) + self._play_btn.toggled.connect(self._on_play_toggled) + + self._slider = QtWidgets.QSlider(QtCore.Qt.Horizontal) + self._slider.setRange(0, self._n - 1) + self._slider.valueChanged.connect(self._on_slider) + + self._label = QtWidgets.QLabel() + self._label.setMinimumWidth(90) + self._update_label(0) + + self._timer = QtCore.QTimer(self) + self._timer.setInterval(100) # ~10 fps + self._timer.timeout.connect(self._advance) + + layout = QtWidgets.QHBoxLayout(self) + layout.setContentsMargins(6, 2, 6, 2) + layout.addWidget(self._play_btn) + layout.addWidget(self._slider) + layout.addWidget(self._label) + + @property + def index(self) -> int: + return self._slider.value() + + def _update_label(self, i: int) -> None: + self._label.setText(f"frame {i + 1} / {self._n}") + + def _on_slider(self, i: int) -> None: + self._update_label(i) + self.frameChanged.emit(i) + + def _on_play_toggled(self, on: bool) -> None: + self._play_btn.setText("⏸" if on else "▶") + (self._timer.start if on else self._timer.stop)() + + def _advance(self) -> None: + self._slider.setValue((self._slider.value() + 1) % self._n) diff --git a/tests/__pycache__/test_accumulate.cpython-312-pytest-9.1.1.pyc b/tests/__pycache__/test_accumulate.cpython-312-pytest-9.1.1.pyc new file mode 100644 index 0000000000000000000000000000000000000000..85019b8e69178fb0008c51bdc4abe42f7d552a51 Binary files /dev/null and b/tests/__pycache__/test_accumulate.cpython-312-pytest-9.1.1.pyc differ diff --git a/tests/__pycache__/test_apairo_adapter.cpython-312-pytest-9.1.1.pyc b/tests/__pycache__/test_apairo_adapter.cpython-312-pytest-9.1.1.pyc new file mode 100644 index 0000000000000000000000000000000000000000..79524e27c918fa678965ab3eae30a178ffcb61fa Binary files /dev/null and b/tests/__pycache__/test_apairo_adapter.cpython-312-pytest-9.1.1.pyc differ diff --git a/tests/__pycache__/test_core.cpython-312-pytest-9.1.1.pyc b/tests/__pycache__/test_core.cpython-312-pytest-9.1.1.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a5fb8d323bd7ffb1bc283b2b082c35b9817c5c84 Binary files /dev/null and b/tests/__pycache__/test_core.cpython-312-pytest-9.1.1.pyc differ diff --git a/tests/__pycache__/test_grid.cpython-312-pytest-9.1.1.pyc b/tests/__pycache__/test_grid.cpython-312-pytest-9.1.1.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b26237ea8b7c1e4a662eeaf4d0ecee212f12f90b Binary files /dev/null and b/tests/__pycache__/test_grid.cpython-312-pytest-9.1.1.pyc differ diff --git a/tests/__pycache__/test_io.cpython-312-pytest-9.1.1.pyc b/tests/__pycache__/test_io.cpython-312-pytest-9.1.1.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b866bd7286e2bfdb5363135e2c7e6a7f76bea229 Binary files /dev/null and b/tests/__pycache__/test_io.cpython-312-pytest-9.1.1.pyc differ diff --git a/tests/__pycache__/test_labels_target.cpython-312-pytest-9.1.1.pyc b/tests/__pycache__/test_labels_target.cpython-312-pytest-9.1.1.pyc new file mode 100644 index 0000000000000000000000000000000000000000..658966cac9e41b09f641215ba6f2215e9bdb9343 Binary files /dev/null and b/tests/__pycache__/test_labels_target.cpython-312-pytest-9.1.1.pyc differ diff --git a/tests/__pycache__/test_point_target.cpython-312-pytest-9.1.1.pyc b/tests/__pycache__/test_point_target.cpython-312-pytest-9.1.1.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7193430057a4b3b0d4b5657c09802e722db7c011 Binary files /dev/null and b/tests/__pycache__/test_point_target.cpython-312-pytest-9.1.1.pyc differ diff --git a/tests/__pycache__/test_projection.cpython-312-pytest-9.1.1.pyc b/tests/__pycache__/test_projection.cpython-312-pytest-9.1.1.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b1597f4bd7441c20d58e828cfbf4020db732f8bb Binary files /dev/null and b/tests/__pycache__/test_projection.cpython-312-pytest-9.1.1.pyc differ diff --git a/tests/test_accumulate.py b/tests/test_accumulate.py new file mode 100644 index 0000000000000000000000000000000000000000..aa99b6f012a42db8b58d28896c5572b46214a6e6 --- /dev/null +++ b/tests/test_accumulate.py @@ -0,0 +1,94 @@ +"""Tests headless : poses, cumul par registration, décumul des labels points.""" + +import numpy as np + +from splasher import ArraySource, ChannelKind, ChannelSpec +from splasher.core.accumulate import accumulate, window_indices +from splasher.core.poses import invert, pose_to_matrix, transform_points +from splasher.core.target import PointTarget + + +def test_pose_to_matrix_forms(): + assert pose_to_matrix(np.eye(4)).shape == (4, 4) + T = pose_to_matrix(np.array([1.0, 2.0, 3.0, 0.0, 0.0, 0.0, 1.0])) # quat identité + assert np.allclose(T[:3, 3], [1, 2, 3]) + assert np.allclose(T[:3, :3], np.eye(3)) + + +def test_invert_and_transform(): + T = np.eye(4) + T[:3, 3] = [5.0, 0.0, 0.0] + pts = np.array([[1.0, 1.0, 0.0]]) + back = transform_points(transform_points(pts, T), invert(T)) + assert np.allclose(back[:, :3], pts) + + +def test_window_indices_clipped(): + assert window_indices(0, 2, 10) == [0, 1, 2] + assert window_indices(5, 2, 10) == [3, 4, 5, 6, 7] + assert window_indices(9, 3, 10) == [6, 7, 8, 9] + + +def _moving_source(n=5): + specs = [ + ChannelSpec("lidar", ChannelKind.POINTCLOUD, np.dtype("float32"), (None, 3)), + ChannelSpec("pose", ChannelKind.POSE, np.dtype("float32"), (4, 4)), + ] + frames = [] + for t in range(n): + # un point fixe dans le MONDE à x=10 ; l'ego avance de 1 par frame en x + pose = np.eye(4, dtype=np.float32) + pose[0, 3] = float(t) + world_x = 10.0 + pt_ego = np.array([[world_x - t, 0.0, 0.0]], dtype=np.float32) # vu depuis l'ego + frames.append({"lidar": pt_ego, "pose": pose}) + return ArraySource(specs, frames) + + +def test_accumulate_registers_to_reference(): + src = _moving_source(5) + ref = 2 + acc = accumulate(src, ref, window_indices(ref, 2, 5), ["lidar"], "pose") + # tous les points (un point monde fixe) doivent retomber au même endroit + # dans le repère du frame ref : x = 10 - ref = 8 + assert len(acc.points) == 5 + assert np.allclose(acc.points[:, 0], 8.0, atol=1e-5) + assert set(acc.frame_id.tolist()) == {0, 1, 2, 3, 4} + + +def test_accumulate_two_channels_chan_and_point_ids(): + specs = [ + ChannelSpec("a", ChannelKind.POINTCLOUD, np.dtype("float32"), (None, 3)), + ChannelSpec("b", ChannelKind.POINTCLOUD, np.dtype("float32"), (None, 3)), + ChannelSpec("pose", ChannelKind.POSE, np.dtype("float32"), (4, 4)), + ] + frame = { + "a": np.zeros((3, 3), np.float32), # 3 points -> chan 0, point_id 0..2 + "b": np.ones((2, 3), np.float32), # 2 points -> chan 1, point_id 3..4 (concat complète) + "pose": np.eye(4, dtype=np.float32), + } + src = ArraySource(specs, [frame]) + acc = accumulate(src, 0, [0], ["a", "b"], "pose") + assert acc.counts[0] == 5 + assert acc.chan_id.tolist() == [0, 0, 0, 1, 1] + assert acc.point_id.tolist() == [0, 1, 2, 3, 4] + # filtre de visibilité : ne garder que le canal b (indice 1) + vis = acc.visible_mask([1]) + assert vis.tolist() == [False, False, False, True, True] + + +def test_decumul_via_apply_scatter(): + # le pinceau touche un point provenant de 3 frames -> labels répartis par frame + src = _moving_source(5) + ref = 2 + acc = accumulate(src, ref, window_indices(ref, 2, 5), ["lidar"], "pose") + pt = PointTarget(ignore_id=0) + frame_to_sel = {int(f): (acc.point_id[acc.frame_id == f], acc.counts[int(f)]) + for f in np.unique(acc.frame_id)} + assert pt.apply_scatter(ref, frame_to_sel, class_id=4) + # chaque frame a reçu son label sur son point 0 + for f in range(5): + assert pt.labels(f).tolist() == [4] + pt.undo(ref) # undo atomique sous le frame de référence : tout revient + for f in range(5): + assert pt.labels(f).tolist() == [0] diff --git a/tests/test_apairo_adapter.py b/tests/test_apairo_adapter.py new file mode 100644 index 0000000000000000000000000000000000000000..a88f81bdcc888de8d0be8af0c73e92ca38d2191e --- /dev/null +++ b/tests/test_apairo_adapter.py @@ -0,0 +1,68 @@ +"""Test headless de l'adaptateur apairo SANS apairo installé. + +On simule un dataset apairo synchrone via un objet duck-typé : l'adaptateur ne doit +dépendre que de l'interface (is_synchronous / keys / __len__ / __getitem__). +""" + +from dataclasses import dataclass + +import numpy as np +import pytest + +from splasher.adapters.apairo_source import ApairoSource, _kind_of +from splasher.core.source import ChannelKind + + +@dataclass +class _FakeSample: + data: dict + timestamp: float | None = None + + +class _FakeDataset: + is_synchronous = True + + def __init__(self): + self.keys = ["lidar", "labels", "cam", "pose"] + self._frames = [ + { + "lidar": np.zeros((100, 4), np.float32), + "labels": np.zeros((100,), np.int64), + "cam": np.zeros((8, 8, 3), np.uint8), + "pose": np.eye(4, dtype=np.float32), + } + for _ in range(2) + ] + + def __len__(self): + return len(self._frames) + + def __getitem__(self, i): + return _FakeSample(self._frames[i], timestamp=None) + + +def test_kind_of(): + assert _kind_of(np.zeros((10, 4))) is ChannelKind.POINTCLOUD + assert _kind_of(np.zeros((8, 8, 3), np.uint8)) is ChannelKind.IMAGE + assert _kind_of(np.eye(4)) is ChannelKind.POSE + assert _kind_of(np.zeros((7,))) is ChannelKind.POSE + assert _kind_of(np.zeros((100,))) is ChannelKind.SCALAR # labels + + +def test_adapter_classifies_and_reads(): + src = ApairoSource(_FakeDataset()) + kinds = {s.name: s.kind for s in src.channels()} + assert kinds["lidar"] is ChannelKind.POINTCLOUD + assert kinds["cam"] is ChannelKind.IMAGE + assert kinds["pose"] is ChannelKind.POSE + assert kinds["labels"] is ChannelKind.SCALAR + assert len(src) == 2 + assert src[0]["lidar"].shape == (100, 4) + + +def test_adapter_rejects_async(): + class Async(_FakeDataset): + is_synchronous = False + + with pytest.raises(ValueError): + ApairoSource(Async()) diff --git a/tests/test_core.py b/tests/test_core.py new file mode 100644 index 0000000000000000000000000000000000000000..4d7b704c16fdbfc1505d314aa0e263bdae1e8b97 --- /dev/null +++ b/tests/test_core.py @@ -0,0 +1,53 @@ +"""Tests headless du cœur (numpy pur) — n'importent jamais l'UI/Qt.""" + +import numpy as np +import pytest + +from splasher import ArraySource, ChannelKind, ChannelSpec, channels_of_kind +from splasher.core.colormap import colormap + + +def _tiny_source(): + specs = [ + ChannelSpec("lidar", ChannelKind.POINTCLOUD, np.dtype("float32"), (None, 4)), + ChannelSpec("cam", ChannelKind.IMAGE, np.dtype("uint8"), (4, 4, 3)), + ChannelSpec("pose", ChannelKind.POSE, np.dtype("float32"), (4, 4)), + ] + frames = [ + { + "lidar": np.zeros((10, 4), np.float32), + "cam": np.zeros((4, 4, 3), np.uint8), + "pose": np.eye(4, dtype=np.float32), + } + for _ in range(3) + ] + return ArraySource(specs, frames) + + +def test_array_source_basic(): + src = _tiny_source() + assert len(src) == 3 + frame = src[0] + assert set(frame.keys()) == {"lidar", "cam", "pose"} + assert frame.timestamp is None # synchrone + assert frame["lidar"].shape == (10, 4) + + +def test_channels_of_kind(): + src = _tiny_source() + assert channels_of_kind(src, ChannelKind.POINTCLOUD) == ["lidar"] + assert channels_of_kind(src, ChannelKind.IMAGE) == ["cam"] + assert channels_of_kind(src, ChannelKind.POSE) == ["pose"] + + +def test_array_source_missing_channel_raises(): + specs = [ChannelSpec("lidar", ChannelKind.POINTCLOUD)] + with pytest.raises(ValueError): + ArraySource(specs, [{"autre": np.zeros((1, 3))}]) + + +def test_colormap_rgba_range(): + rgba = colormap(np.array([0.0, 1.0, 2.0, np.nan])) + assert rgba.shape == (4, 4) + assert rgba.min() >= 0.0 and rgba.max() <= 1.0 + assert np.allclose(rgba[:, 3], 1.0) diff --git a/tests/test_grid.py b/tests/test_grid.py new file mode 100644 index 0000000000000000000000000000000000000000..57b0bf535972873be54dae4f57c5dc749586fa0e --- /dev/null +++ b/tests/test_grid.py @@ -0,0 +1,58 @@ +"""Tests headless de la grille (numpy pur).""" + +import numpy as np +import pytest + +from splasher import Grid, grid_from_points + + +def test_dims(): + g = Grid(0.0, 10.0, 0.0, 4.0, 1.0) + assert g.cols == 10 + assert g.rows == 4 + assert g.shape == (4, 10) + assert g.empty_raster(fill=-1).shape == (4, 10) + + +def test_dims_ceil(): + g = Grid(0.0, 10.0, 0.0, 5.0, 3.0) + assert g.cols == 4 # ceil(10/3) + assert g.rows == 2 # ceil(5/3) + + +def test_world_to_cell(): + g = Grid(0.0, 10.0, 0.0, 10.0, 1.0) + xy = np.array([[0.5, 0.5], [9.5, 9.5], [-1.0, 5.0], [5.0, 11.0]]) + ij, valid = g.world_to_cell(xy) + assert ij[0].tolist() == [0, 0] # [i(y), j(x)] + assert ij[1].tolist() == [9, 9] + assert valid.tolist() == [True, True, False, False] + + +def test_cell_to_world_roundtrip(): + g = Grid(-5.0, 5.0, -5.0, 5.0, 2.0) + x, y = g.cell_to_world(0, 0) + ij, valid = g.world_to_cell(np.array([[x, y]])) + assert valid[0] + assert ij[0].tolist() == [0, 0] + + +def test_invalid(): + with pytest.raises(ValueError): + Grid(0.0, 0.0, 0.0, 1.0, 1.0) + with pytest.raises(ValueError): + Grid(0.0, 1.0, 0.0, 1.0, 0.0) + + +def test_grid_from_points(): + xy = np.array([[1.0, 2.0], [3.0, 8.0]]) + g = grid_from_points(xy, cell_size=1.0, margin=1.0) + assert g.xmin <= 0.0 and g.ymin <= 1.0 + assert g.xmax >= 4.0 and g.ymax >= 9.0 + + +def test_line_segments_shape(): + g = Grid(0.0, 4.0, 0.0, 2.0, 1.0) + xs, ys = g.line_segments() + # (cols+1) verticales + (rows+1) horizontales, 2 points chacune + assert len(xs) == len(ys) == 2 * ((g.cols + 1) + (g.rows + 1)) diff --git a/tests/test_io.py b/tests/test_io.py new file mode 100644 index 0000000000000000000000000000000000000000..f3d5ba4887d68fdd271e611b70aa3c862efcac1f --- /dev/null +++ b/tests/test_io.py @@ -0,0 +1,37 @@ +"""Test headless du round-trip de session (save -> load).""" + +import numpy as np + +from splasher import Grid +from splasher.core.io import load_session, save_session +from splasher.core.labels import LabelSet +from splasher.core.target import GridTarget, PointTarget + + +def test_session_roundtrip(tmp_path): + grid = Grid(0.0, 8.0, 0.0, 6.0, 1.0) + ls = LabelSet.default() + + gt = GridTarget(grid, ignore_id=0) + gt.apply(3, (1.0, 1.0, 4.0, 4.0), class_id=2) + + pt = PointTarget(ignore_id=0) + xy = np.array([[0.5, 0.5], [2.0, 2.0]]) + pt.apply(3, (1.0, 1.0, 3.0, 3.0), class_id=1, xy=xy) + + out = save_session(tmp_path, grid=grid, labelset=ls, grid_target=gt, point_target=pt) + assert (out / "session.json").exists() + assert (out / "grid" / "frame_00003.npy").exists() + assert (out / "points" / "frame_00003.npy").exists() + + data = load_session(out) + assert data["grid"].shape == grid.shape + assert data["labelset"].name_of(2) == ls.name_of(2) + + np.testing.assert_array_equal(data["grid_labels"][3], gt.raster(3)) + np.testing.assert_array_equal(data["point_labels"][3], pt.labels(3)) + + # ré-injection dans des cibles neuves + gt2 = GridTarget(grid) + gt2.load_rasters(data["grid_labels"]) + assert (gt2.raster(3) == gt.raster(3)).all() diff --git a/tests/test_labels_target.py b/tests/test_labels_target.py new file mode 100644 index 0000000000000000000000000000000000000000..b370cdabf45aefb1690254e2fbbd9d82b3884060 --- /dev/null +++ b/tests/test_labels_target.py @@ -0,0 +1,72 @@ +"""Tests headless : LabelSet (colorize, IO) et GridTarget (apply/undo/clear).""" + +import numpy as np + +from splasher import Grid +from splasher.core.labels import LabelClass, LabelSet +from splasher.core.target import GridTarget + + +def test_labelset_colorize_ignore_transparent(): + ls = LabelSet.default() + raster = np.array([[0, 1], [2, 3]], dtype=np.int32) + rgba = ls.colorize(raster) + assert rgba.shape == (2, 2, 4) + assert rgba[0, 0, 3] == 0 # ignore -> transparent + assert tuple(rgba[0, 1, :3]) == (60, 200, 70) # classe 1 + assert rgba[1, 0, 3] == 255 # classe 2 opaque + + +def test_labelset_json_roundtrip(tmp_path): + ls = LabelSet([LabelClass(0, "void", (0, 0, 0)), LabelClass(5, "x", (1, 2, 3))], ignore_id=0) + p = tmp_path / "labels.json" + ls.save(p) + back = LabelSet.load(p) + assert back.ignore_id == 0 + assert back.name_of(5) == "x" + assert back.color_of(5) == (1, 2, 3) + + +def test_gridtarget_apply_and_undo(): + g = Grid(0.0, 4.0, 0.0, 4.0, 1.0) + t = GridTarget(g, ignore_id=0) + assert not t.has(0) + assert t.apply(0, (1.0, 1.0, 3.0, 3.0), class_id=2) + r = t.raster(0) + assert (r[1:3, 1:3] == 2).all() + assert r[0, 0] == 0 # hors rectangle + t.undo(0) + assert (t.raster(0) == 0).all() + + +def test_gridtarget_apply_mask_and_undo(): + g = Grid(0.0, 4.0, 0.0, 4.0, 1.0) # 4x4 + t = GridTarget(g, ignore_id=0) + mask = np.zeros((4, 4), dtype=bool) + mask[0, 0] = mask[3, 3] = mask[1, 2] = True # cellules non contiguës (sélection) + assert t.apply_mask(0, mask, class_id=3) + r = t.raster(0) + assert r[0, 0] == 3 and r[3, 3] == 3 and r[1, 2] == 3 + assert r[2, 2] == 0 + t.undo(0) + assert (t.raster(0) == 0).all() + + +def test_gridtarget_apply_mask_empty_false(): + g = Grid(0.0, 4.0, 0.0, 4.0, 1.0) + t = GridTarget(g) + assert t.apply_mask(0, np.zeros((4, 4), dtype=bool), class_id=1) is False + + +def test_gridtarget_rect_outside_returns_false(): + g = Grid(0.0, 4.0, 0.0, 4.0, 1.0) + t = GridTarget(g) + assert t.apply(0, (100.0, 100.0, 200.0, 200.0), class_id=1) is False + + +def test_gridtarget_clear(): + g = Grid(0.0, 4.0, 0.0, 4.0, 1.0) + t = GridTarget(g) + t.apply(0, (0.0, 0.0, 4.0, 4.0), class_id=1) + t.clear(0) + assert (t.raster(0) == 0).all() diff --git a/tests/test_point_target.py b/tests/test_point_target.py new file mode 100644 index 0000000000000000000000000000000000000000..811e7651fca5613070a7c978870443773488bac5 --- /dev/null +++ b/tests/test_point_target.py @@ -0,0 +1,38 @@ +"""Tests headless de PointTarget (labels par point).""" + +import numpy as np + +from splasher.core.target import PointTarget + + +def test_apply_assigns_class_to_points_in_rect(): + xy = np.array([[0.0, 0.0], [2.0, 2.0], [5.0, 5.0]]) + t = PointTarget(ignore_id=0) + assert t.apply(0, (1.0, 1.0, 3.0, 3.0), class_id=7, xy=xy) + lab = t.labels(0) + assert lab.tolist() == [0, 7, 0] + + +def test_apply_no_points_returns_false(): + xy = np.array([[0.0, 0.0]]) + t = PointTarget() + assert t.apply(0, (10.0, 10.0, 20.0, 20.0), class_id=1, xy=xy) is False + assert not t.has(0) + + +def test_undo_restores(): + xy = np.array([[0.0, 0.0], [2.0, 2.0]]) + t = PointTarget() + t.apply(0, (1.0, 1.0, 3.0, 3.0), class_id=3, xy=xy) + t.apply(0, (-1.0, -1.0, 1.0, 1.0), class_id=4, xy=xy) + assert t.labels(0).tolist() == [4, 3] + t.undo(0) + assert t.labels(0).tolist() == [0, 3] + + +def test_clear(): + xy = np.array([[2.0, 2.0]]) + t = PointTarget() + t.apply(0, (1.0, 1.0, 3.0, 3.0), class_id=5, xy=xy) + t.clear(0) + assert t.labels(0).tolist() == [0] diff --git a/tests/test_projection.py b/tests/test_projection.py new file mode 100644 index 0000000000000000000000000000000000000000..d41468398118aea1688f1269119b458da1f3a3eb --- /dev/null +++ b/tests/test_projection.py @@ -0,0 +1,58 @@ +"""Tests headless de la projection BEV et de la sélection rectangle.""" + +import numpy as np + +from splasher import Grid +from splasher.core.projection import ( + bev_count, + bev_image, + bev_max_height, + cells_in_rect, + points_in_rect, +) + + +def _grid(): + return Grid(0.0, 4.0, 0.0, 4.0, 1.0) # 4x4 + + +def test_bev_max_height_and_empty(): + g = _grid() + pts = np.array([[0.5, 0.5, 1.0], [0.6, 0.6, 3.0], [3.5, 3.5, 2.0]]) + h = bev_max_height(pts, g) + assert h.shape == (4, 4) + assert h[0, 0] == 3.0 # max des deux points de la cellule (0,0) + assert h[3, 3] == 2.0 + assert np.isnan(h[2, 2]) # cellule vide + + +def test_bev_count(): + g = _grid() + pts = np.array([[0.5, 0.5, 0.0], [0.6, 0.6, 0.0]]) + c = bev_count(pts, g) + assert c[0, 0] == 2.0 + assert np.isnan(c[1, 1]) + + +def test_bev_image_alpha(): + g = _grid() + pts = np.array([[0.5, 0.5, 1.0]]) + img = bev_image(bev_max_height(pts, g)) + assert img.shape == (4, 4, 4) + assert img[0, 0, 3] > 0 # cellule remplie opaque + assert img[2, 2, 3] == 0 # cellule vide transparente + + +def test_cells_in_rect(): + g = _grid() + si, sj = cells_in_rect((1.2, 0.5, 2.9, 3.4), g) + assert (si.start, si.stop) == (0, 4) + assert (sj.start, sj.stop) == (1, 3) + + +def test_points_in_rect(): + xy = np.array([[0.0, 0.0], [2.0, 2.0], [5.0, 5.0]]) + mask = points_in_rect(xy, (1.0, 1.0, 3.0, 3.0)) + assert mask.tolist() == [False, True, False] + # ordre des coins indifférent + assert points_in_rect(xy, (3.0, 3.0, 1.0, 1.0)).tolist() == [False, True, False] diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000000000000000000000000000000000000..3bcf12ba3d4497930d223ba673fac4ed62377804 --- /dev/null +++ b/uv.lock @@ -0,0 +1,388 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version < '3.12'", +] + +[[package]] +name = "apairo" +version = "0.4.0" +source = { git = "https://github.com/apairo-robotics/apairo.git#2d838c9986bface222b5396206b592fe1e46282c" } +dependencies = [ + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "pyyaml" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "numpy" +version = "2.4.6" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.12'", +] +sdist = { url = "https://files.pythonhosted.org/packages/d0/ad/fed0499ce6a338d2a03ebae59cd15093910c8875328855781952abf6c2fe/numpy-2.4.6.tar.gz", hash = "sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda", size = 20735807, upload-time = "2026-05-18T23:37:14.07Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/49/ec46835a70be8fa6446c495126ac84fdb28cb2558e1620ffb87a10c8b64c/numpy-2.4.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0280e0356c0829a18d9de1cb7eee50ec22ca639878d7240307ca0943d73cd2c4", size = 16969194, upload-time = "2026-05-18T23:33:13.503Z" }, + { url = "https://files.pythonhosted.org/packages/0e/0d/f5957185c0ee2f3e12f78715aa9e3b353fd83633316c8532b38faa37e3f6/numpy-2.4.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:110f8b71aacb688ec69062bb7f6938a0f8acb01b7c1c4beb453c65b6d234584d", size = 14964111, upload-time = "2026-05-18T23:33:17.795Z" }, + { url = "https://files.pythonhosted.org/packages/ad/40/40a40ee0ddf7ceb782c49af278894b686e586d65d8c1889c8b5da01a3d7d/numpy-2.4.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:4cfe66903cc32a9921a6733d96b19bb6abf310397581bbad89c228f5abaf0ee8", size = 5469159, upload-time = "2026-05-18T23:33:20.654Z" }, + { url = "https://files.pythonhosted.org/packages/63/13/f9a8046535cb21deae82f8d03de9617e08882d274fad2539630761888228/numpy-2.4.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:8155154c7c691289fe18f510b5d4657c68c67989f293f0535a91360392ff6538", size = 6798936, upload-time = "2026-05-18T23:33:22.987Z" }, + { url = "https://files.pythonhosted.org/packages/33/a8/6fa8c1a345a8c85dbb21932c447bee07c30a2c2a3f31e369c0a84b300147/numpy-2.4.6-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ab0a9c4ffb1a6d95ef519fe4247dba8eb6b18ad93999f76b7f657039acabd47", size = 15966692, upload-time = "2026-05-18T23:33:26.62Z" }, + { url = "https://files.pythonhosted.org/packages/02/03/74fe2a4cb3817d94d86402f2506554130a2f01414e299b5a843e5a8a957f/numpy-2.4.6-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89cd468399cfd2504718f0ba50e410dca55a170b61a02ad92bb18c8a65186e93", size = 16918164, upload-time = "2026-05-18T23:33:29.955Z" }, + { url = "https://files.pythonhosted.org/packages/c5/80/3615be3313f7e7696609bc194b9f0101da809df79e859bdb84e0cd043f46/numpy-2.4.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c2d37ab77531417474168eb79d6d80b14f821a966818505d03013d0833edb7a8", size = 17322877, upload-time = "2026-05-18T23:33:34.724Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ac/a691e0fe2675e370d0e08ff905adc49a1c8830e8cae03efe4477e92cd55d/numpy-2.4.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f407cb6b8e9d6d8c626bc73c945db1706035af8fd632295547bf1c9e46d092d6", size = 18651487, upload-time = "2026-05-18T23:33:38.217Z" }, + { url = "https://files.pythonhosted.org/packages/15/a7/9bc1cd626d7bf6869bfedf27b91b6ab5dd607758bf8e959d6fa80c6a59cb/numpy-2.4.6-cp311-cp311-win32.whl", hash = "sha256:ddea102b48f9e339f3948bf22040944184627a30fdf7f858667673b9c5f033c8", size = 6233945, upload-time = "2026-05-18T23:33:41.331Z" }, + { url = "https://files.pythonhosted.org/packages/c5/31/7fc6239c12bce7e931463251cca4426c465e1876ba3cc785402ef4dd8f4e/numpy-2.4.6-cp311-cp311-win_amd64.whl", hash = "sha256:1e254a00cdf42b1e4d5b3d68d33af63268d41340d8885df2ab6470f2e1500147", size = 12608406, upload-time = "2026-05-18T23:33:44.131Z" }, + { url = "https://files.pythonhosted.org/packages/27/83/140f85a466595a16382996a1bf06b2b54bcd597488921b0c9daaeeda72af/numpy-2.4.6-cp311-cp311-win_arm64.whl", hash = "sha256:ed9749eef4cbd126da3dc1d6bcb3a57f5eb7ac6a6484146bdbf743f552dfc577", size = 10479528, upload-time = "2026-05-18T23:33:50.725Z" }, + { url = "https://files.pythonhosted.org/packages/95/2a/3d7b5ac8aac24feaf9ad7ed58f45b0bbc06d37e4338ae84c9f2298b570f9/numpy-2.4.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:001fbb8e08d942dd57599e781f2472269ee7f2755fae407b4f67b2f0b17da3f1", size = 16689119, upload-time = "2026-05-18T23:33:54.065Z" }, + { url = "https://files.pythonhosted.org/packages/ea/12/92c4c131527599e8288d6918e888d88726f84d805d784b771f32408aeaef/numpy-2.4.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ebfb099f8dcf083deef3ac1ca4c1503f387cf76296fcb3816b66f5ecb5f54fdb", size = 14699246, upload-time = "2026-05-18T23:33:57.621Z" }, + { url = "https://files.pythonhosted.org/packages/ad/fe/c0a6b7b2ca128a8fb228575147073b660656734b8ebe4d76c8fd748dcc79/numpy-2.4.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:3213d622a0283a39a93d188f3cf72b26862df52fbb4ca3697f51705016523d41", size = 5204410, upload-time = "2026-05-18T23:34:00.302Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d4/9770d14ba719432bb90a421bfd443872ed0f70f7264b64bec12ea363d5fd/numpy-2.4.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:357cc07a6d7b0b182ff02249616a03742827ebb1277546b5c7cd7f7620a45698", size = 6551240, upload-time = "2026-05-18T23:34:02.852Z" }, + { url = "https://files.pythonhosted.org/packages/c9/c6/50a46a6205feba2343f1d6d17438107c5dc491ed1c736e6ea68689fd906b/numpy-2.4.6-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f9fb9157b4ce2971008323afe46053787b526ef624fea915b261468a8421a0f", size = 15671012, upload-time = "2026-05-18T23:34:05.485Z" }, + { url = "https://files.pythonhosted.org/packages/99/60/14115e6364fa676c5397c2ad3004e527e9aa487abf5d0706ec81bbd08529/numpy-2.4.6-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90f9849678c75fe7afa2d348ac842c168b0a4d3d61919687216dfc547976d853", size = 16645538, upload-time = "2026-05-18T23:34:09.265Z" }, + { url = "https://files.pythonhosted.org/packages/ae/c5/693cbe59e57db94d2231fa519ca3978dc9e19da5a8f088588f5c6e947ff2/numpy-2.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c1a2af6c6ef86344a6b0db6b97834208bf598db514f2b155042439b62605601a", size = 17020706, upload-time = "2026-05-18T23:34:13.053Z" }, + { url = "https://files.pythonhosted.org/packages/ef/fc/85b7c4eff9b4966ade25c2273cf7e7012e92366c032058653934b37de044/numpy-2.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e5805d5a22fd19c8ccff10a9561f9df94436b0545619ea579db2d3c35294bce2", size = 18368541, upload-time = "2026-05-18T23:34:17.024Z" }, + { url = "https://files.pythonhosted.org/packages/f6/81/e1b27545deedce7f4a0b348618c6b62d74e36a4dc9ccd42f3eb2f85eee32/numpy-2.4.6-cp312-cp312-win32.whl", hash = "sha256:e3eeb0aabd6bd5ce64faae67e9935203a6991b4bc2a485a767fbafb2c5125f45", size = 5962825, upload-time = "2026-05-18T23:34:20.3Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ca/feab00bd44aa5fe1ad2c18f08b4d3bb92e26484b0b1d1443897809ed528c/numpy-2.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:d8e8286dd7cea7895157318d1b91cdacac64c479f3cbc8dce548331728484751", size = 12321687, upload-time = "2026-05-18T23:34:23.095Z" }, + { url = "https://files.pythonhosted.org/packages/63/cf/5a6d34850a39d1093558564f77ee8e8e0bee5061151b8f05a55711001ec7/numpy-2.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:4081eb135ac24158bd51cdfbef16f1c64df7063b1143f24731387137c092bec8", size = 10221482, upload-time = "2026-05-18T23:34:25.876Z" }, + { url = "https://files.pythonhosted.org/packages/fb/82/bdab26d7438c6791ca31b7c024ca37c1eab8b726ba236129005cd4a06e45/numpy-2.4.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:511dbaf848decaaaf4b4ca48032619fb3138710c4bf7da7617765edad1ef96b0", size = 16684648, upload-time = "2026-05-18T23:34:29.41Z" }, + { url = "https://files.pythonhosted.org/packages/1b/30/a80189bcc7f5e4258b3fbc3968d909d1756f54d023299ecc39ad6fdb9ef8/numpy-2.4.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bf162abab1c1a736333192707cef898e735a5ca00f38f27eeedf44b39d9e85eb", size = 14693902, upload-time = "2026-05-18T23:34:33.013Z" }, + { url = "https://files.pythonhosted.org/packages/97/12/70b5d0d7c15e1ebb8a6a84a8caa1d19e181d84fb58bb6d70aca29099dec1/numpy-2.4.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:043191bfa8eab18c776647b62723ac9dddece59743b13f49b2016094129c2b3f", size = 5198992, upload-time = "2026-05-18T23:34:36.132Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8c/ebd2a8f8a83541f8d38cc5667e8c2b69cecfd30da6e45693e8158857d44b/numpy-2.4.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:6180d8b35af935aed8ece3a85e0a43f87393ae0ac87c8d2c8bd2c993f7270ef3", size = 6546944, upload-time = "2026-05-18T23:34:38.484Z" }, + { url = "https://files.pythonhosted.org/packages/bb/c5/7b863a97a91671a0338f4253bd3b5a3d3852f0692dae91711c9f4a10e787/numpy-2.4.6-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72fbe16c6fac95aedf5937fa873445cec2110be35d8a4e9433d7501fd98dae6b", size = 15669392, upload-time = "2026-05-18T23:34:41.257Z" }, + { url = "https://files.pythonhosted.org/packages/a5/9d/3584b9984ca4c047aea75214ce1a4c4c73d849bd71b604264b7f5653f8a8/numpy-2.4.6-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7830bab239b79cda9c08c2da014761cafb48da6150e1da17ac06283f43b6089", size = 16633220, upload-time = "2026-05-18T23:34:45.075Z" }, + { url = "https://files.pythonhosted.org/packages/05/ae/7c67fba23bd98caec7c99261f3a16072ade14813486b0282cb29846de832/numpy-2.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ef4aea96ce4d3b074422cb4f2f64e216bf9e213004bb58ecfdf50ea02ea8eb9a", size = 17020800, upload-time = "2026-05-18T23:34:49.065Z" }, + { url = "https://files.pythonhosted.org/packages/d9/5d/3b6725cb31d983c5e66916f5d36f6d7e5521129e4c4404d64f918292a5b6/numpy-2.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dfa20cc6ca228e6b155b11da03825975ce66aea520985dbbddf0f2a5a495c605", size = 18357600, upload-time = "2026-05-18T23:34:52.709Z" }, + { url = "https://files.pythonhosted.org/packages/f7/da/2ccc6c2fe8898dee01d90c75c5f5f914a23daf99e3e0f59516a08760c8b5/numpy-2.4.6-cp313-cp313-win32.whl", hash = "sha256:56b39e5e0622a09a25bf5baf62f4bcf0cb8a41ae6e2819cf49bbc5a74c083f91", size = 5961134, upload-time = "2026-05-18T23:34:55.618Z" }, + { url = "https://files.pythonhosted.org/packages/b5/cd/9cc4dc876fb065d5c220aae4d5e14826b2715331bb7618ce1fb07a679d99/numpy-2.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:c4fc99836233ea196540b17ab0983aff60ed07941751930f5f4d05bc3b3b7359", size = 12318598, upload-time = "2026-05-18T23:34:58.928Z" }, + { url = "https://files.pythonhosted.org/packages/39/1e/c0bcba1f8694116485fe28fd1be698c278fcda4141c5b0e53a2aed8b12a8/numpy-2.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a7c711e21628b52034bb5ab8d1bce291f752fcc5e92accc615778acee1ff4778", size = 10222272, upload-time = "2026-05-18T23:35:02.167Z" }, + { url = "https://files.pythonhosted.org/packages/63/6d/cc5619247c8f4204e507f5883528372e4ac4bb189e579fb859a12e480b1f/numpy-2.4.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:112b06a867b235ef466ed3508ddf0238050df9c727cafb5301ac385b899189a1", size = 14821197, upload-time = "2026-05-18T23:35:05.468Z" }, + { url = "https://files.pythonhosted.org/packages/00/58/f1c39161c87d9e9bed660f1ed4bafc0e403d5ec9650b6dd77aead07d489b/numpy-2.4.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:eaf7fa2de5c0be8ae6ff8e9bea2ccd725e980541244521d8d4b5f3354a27babe", size = 5326287, upload-time = "2026-05-18T23:35:08.693Z" }, + { url = "https://files.pythonhosted.org/packages/af/57/3917ab0fd97f271a8694513581b8a36c655f111c446852c302f04ccdb6fc/numpy-2.4.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:7265a2f3d436e54ef9f2b52b5c937e6be778781bd97a590319d7348f1c1ca997", size = 6646763, upload-time = "2026-05-18T23:35:11.459Z" }, + { url = "https://files.pythonhosted.org/packages/eb/0f/037e64c494b67581ae18193d770adef354c41f3f2c8ebf865602d949bf8f/numpy-2.4.6-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f74a575920ab21fe304421a3fc28793d82e299cae9eccb37084e9fc7f3617c20", size = 15728070, upload-time = "2026-05-18T23:35:14.79Z" }, + { url = "https://files.pythonhosted.org/packages/21/a6/5d2bae9c9542eb4df16dc9c46dc79c186e9bad53805dfa5399a6023c6db0/numpy-2.4.6-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ede83e07a75dd06bc501566c1eca2afc0d61677c1472ac9ad93fdee6e638a48d", size = 16681752, upload-time = "2026-05-18T23:35:18.836Z" }, + { url = "https://files.pythonhosted.org/packages/92/14/23d1dfb410ae362cd59ce53e936b1513d545eb40db3949ced632e19a459e/numpy-2.4.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:68bb27509ac1b9a3443094260f6326150663b06abe40b73a2f81160623da5b67", size = 17086024, upload-time = "2026-05-18T23:35:22.52Z" }, + { url = "https://files.pythonhosted.org/packages/4b/6e/23595a2c642cdf3bc567877064bdd7f91c8b0038a4453cf2daf7248eafe9/numpy-2.4.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a0df0043bdb289bde1f62da130d20df23d58b45429f752bc7a8fc5325a225ecd", size = 18403398, upload-time = "2026-05-18T23:35:26.398Z" }, + { url = "https://files.pythonhosted.org/packages/8a/90/0ac3bc947217e66dec77e7cbc6a1979d1af70b6461b82f620d3bccd5e4c8/numpy-2.4.6-cp313-cp313t-win32.whl", hash = "sha256:29a287e0cf63ff528da061de6b9f64a4618da591ca1046aafc54062e40ca7eab", size = 6084971, upload-time = "2026-05-18T23:35:29.387Z" }, + { url = "https://files.pythonhosted.org/packages/77/71/5673e351671a1d2bd6063b91b44f70c0affea7d1516fa7a6572941ba4aa1/numpy-2.4.6-cp313-cp313t-win_amd64.whl", hash = "sha256:25c692919ac5a01f170a3bfcd62d745b24fd095c353d50812637d6fcab442e75", size = 12458532, upload-time = "2026-05-18T23:35:32.175Z" }, + { url = "https://files.pythonhosted.org/packages/3f/88/19d3503c5046e688f049274b27a3ef3d771152fa80d3ba3d01a3dff61abe/numpy-2.4.6-cp313-cp313t-win_arm64.whl", hash = "sha256:1e978ec1e8bd0e0e4de6bb75de9d30cbb74db6b6a2bb727618613703ca0167dd", size = 10291881, upload-time = "2026-05-18T23:35:35.465Z" }, + { url = "https://files.pythonhosted.org/packages/f8/91/3ab2044d05fd16d343c5ac2e69b127f1b2854040dd20b193257c78028bd3/numpy-2.4.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06ca2f61ec4385a07a6977c55ba998a4466c123642b4a32694d3128fce18c079", size = 16683458, upload-time = "2026-05-18T23:35:38.353Z" }, + { url = "https://files.pythonhosted.org/packages/8e/62/764ce66fa4147ae6d73071a3abf804ffe606f174618697c571acdf26a7c9/numpy-2.4.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:38efbc8de75c7a0fc1ac190162d892787f3f47b57cc291231aafee36b80982b7", size = 14704559, upload-time = "2026-05-18T23:35:42.14Z" }, + { url = "https://files.pythonhosted.org/packages/60/61/23f27c172f022e04025b7dc2367f4d63c1a398120607ec896228649a6f48/numpy-2.4.6-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:d581b735e177fdcdce6fed8e7e8880a3fb6ee4e3653a3ac6af01c6f4c03effc5", size = 5209716, upload-time = "2026-05-18T23:35:45.377Z" }, + { url = "https://files.pythonhosted.org/packages/03/71/21cf70dc6ea3e3acb95fc53a265b2fc248b981f0194ceb5b475271b8809d/numpy-2.4.6-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:0a041d3d761dc3c35cc56ce0351506a02bcbc25f7b169f652435141a17db9096", size = 6543947, upload-time = "2026-05-18T23:35:47.926Z" }, + { url = "https://files.pythonhosted.org/packages/d5/91/64288395ee1799bd2e0b04a305dce9666da90c961e1f3fe982a05ee1c036/numpy-2.4.6-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40fdc1ae7125e518ea98e53e69a4ebc27e1fd50510c47b7ea130cf21e5e1d42b", size = 15685197, upload-time = "2026-05-18T23:35:50.863Z" }, + { url = "https://files.pythonhosted.org/packages/f3/eb/ebffaa97dc55502df69584a8f0dcf07f69a3e0b3e2323670a2722db9aa39/numpy-2.4.6-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2c306dea656c12c68f51f4cea133cbe78ca7435eb28c735eac1d3ebe73be6e8", size = 16638245, upload-time = "2026-05-18T23:35:54.752Z" }, + { url = "https://files.pythonhosted.org/packages/b8/0b/54f9da33128d7e350fab89c7455902eeae70349ee52bddb448dc4a576f45/numpy-2.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:33111801a01c12a8a1e3721f0a9232f8cfc8ae2c6b7098167e6f623c6073f402", size = 17036587, upload-time = "2026-05-18T23:35:58.355Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f0/fdebc1052db1cc37c64beb22072d67cd6d1c71adca1299f53dec2b5e20d3/numpy-2.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ae506e6902902557576a26ff33eda8695e7ecb3cb36c3b573a0765dee114ebdb", size = 18363226, upload-time = "2026-05-18T23:36:02.845Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b4/298628d98c72b57e57f7165ae6a481a1deaf6f3c28262a6e4c739c275930/numpy-2.4.6-cp314-cp314-win32.whl", hash = "sha256:aaf159caa35993cb1f56fb9b8e4610d35758e7ca005412eb1daa856a78c9c4b1", size = 6010196, upload-time = "2026-05-18T23:36:05.92Z" }, + { url = "https://files.pythonhosted.org/packages/df/ac/46de6dda46478f7942f839e094970be2d4a861e005c4b3bf07c92e291a09/numpy-2.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:b507f5c4c1d508876d1819b6bf9a49d365b96320b5d4993426b33a23ca4b8261", size = 12450334, upload-time = "2026-05-18T23:36:09.107Z" }, + { url = "https://files.pythonhosted.org/packages/78/92/b8b798ac784102c0da830d2257d59358e3d3d90d1e2b3f2575dad976c5cf/numpy-2.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:6f41ae150c4e32db4f3310cdaf64b1593a03dbabe29eec77fc9b50fe64061df6", size = 10495678, upload-time = "2026-05-18T23:36:12.766Z" }, + { url = "https://files.pythonhosted.org/packages/30/34/ec28d1aa8115971537c01469ab2011ee96827930f0a124de1000cc2a7ed7/numpy-2.4.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ece3d2cfe132e7d51f44a832b303895e6f2d499c5e74dfbdb06ee246147a304a", size = 14823672, upload-time = "2026-05-18T23:36:16.473Z" }, + { url = "https://files.pythonhosted.org/packages/16/bd/f6d1fede4e54e8042a7ff97bb495510f3c220f94bcd9e8b228e87c92cc0d/numpy-2.4.6-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:e3e5193ef5a3dc73bceee50f7fdc2c90dbb76c42df8d8fae3d1067a583df579e", size = 5328731, upload-time = "2026-05-18T23:36:19.767Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f0/e105b9e2fd728a9910103884decd6951d9dd73896b914a98d9a231de02ee/numpy-2.4.6-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:17f9ade344e7d9b464a084d69bcf18fc691cb1db67c62ed80820bf4926d78f0e", size = 6649805, upload-time = "2026-05-18T23:36:22.266Z" }, + { url = "https://files.pythonhosted.org/packages/82/dd/1206a7ca6ab15e3f02069707ca96222e202af681bb73756da7527f3cb837/numpy-2.4.6-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cd5ffd25db4e7ba6a375693b3fc0fc1791ec636c17db3720da19bde7180ec43", size = 15730496, upload-time = "2026-05-18T23:36:25.713Z" }, + { url = "https://files.pythonhosted.org/packages/51/e7/38d3ea825dcab85a591734decb2f6c67caa7c8367d374df1a1c3842f9b07/numpy-2.4.6-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d92c3819208a60205a12a245c91ad70cb0a85336659b19b834205573ac8456e", size = 16679616, upload-time = "2026-05-18T23:36:29.652Z" }, + { url = "https://files.pythonhosted.org/packages/93/b7/caabfdf53edf663e0b4eb74d7d405d83baef09eb5e83bcd32d601d72b93e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e85b752a1e912b70eaad4fafbd4d1238007ab221de2009b9a2f5ae7461239895", size = 17085145, upload-time = "2026-05-18T23:36:33.449Z" }, + { url = "https://files.pythonhosted.org/packages/f9/45/68d7c33a6bcf3e5aa3bdbd57a367e6f615286dfd6482f97e8ffeb734306e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:29cb7f67d10b479ff07c17d33e39f78c07f71c40ef30d63c153d340e96cd3fb4", size = 18403813, upload-time = "2026-05-18T23:36:37.369Z" }, + { url = "https://files.pythonhosted.org/packages/9c/50/0753655aa844c99cd9e018aacf76f130f1bd81d881bb74bc0aef5d73a8ba/numpy-2.4.6-cp314-cp314t-win32.whl", hash = "sha256:260a5d70215b61ab4fadf5c7baacd64821842975eea312125ed3c39a6391b063", size = 6156982, upload-time = "2026-05-18T23:36:40.817Z" }, + { url = "https://files.pythonhosted.org/packages/b2/d4/7c67becf668f973cb490cec3e98dfd799d866f9c989a54d355672cfa0db6/numpy-2.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:81a1cca95ed5bb92aa8b10dd2cdc9a0d3853a50fad926c28b5d7e8ea54389627", size = 12638908, upload-time = "2026-05-18T23:36:43.996Z" }, + { url = "https://files.pythonhosted.org/packages/43/bb/e1c71a4295b1b1d1393d50dbb4f2a36283c6859d9d3892e84f00ec5a91d5/numpy-2.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:0c9136e14ed34a9e343a31c533d78a9813a69a3148332bce5e9821cb2f996e66", size = 10565867, upload-time = "2026-05-18T23:36:47.114Z" }, + { url = "https://files.pythonhosted.org/packages/de/12/b422cc84439adc0d00de605bf4a308890ae5c26f2c71fbd73e5d08fbb0dd/numpy-2.4.6-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:55cced7c52e981362f708ad635198e97a752dfba412cc03c23bbf3bd8d5cd662", size = 16847511, upload-time = "2026-05-18T23:36:50.673Z" }, + { url = "https://files.pythonhosted.org/packages/44/53/f481bef68011740f8849418d82db07230e825013f31f4eef5ba5b805316a/numpy-2.4.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d6da64deb6b8ed903e7560180a92f2d804ee1ba5eeb849ac2748b8c1aba1f6d7", size = 14889064, upload-time = "2026-05-18T23:36:53.879Z" }, + { url = "https://files.pythonhosted.org/packages/7f/57/42ed575c10ced8af951d426bc4e1f8aff16fd851db33f067036215a7f860/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:68a5124b13fa6cc2086764a20005d30bc0548146f7f5322f02fce212ca14317f", size = 5394157, upload-time = "2026-05-18T23:36:57.194Z" }, + { url = "https://files.pythonhosted.org/packages/6a/ef/f66cc724fcc36c1e364c67f51ae9146090b8b584f27d58b97fdae3edd737/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:948424b06129ce883307e8cff868c31396d8dc7630a59c61d70d98dbe70f222c", size = 6708728, upload-time = "2026-05-18T23:36:59.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/9c/c531f2293b91265d8b48e9b329f54fdd7ffae73cb4134ea10cca4237e9cc/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbbdb29840ca3d91ee0fece42fc29278886d908280bfec0a5846c6f901a3eb0", size = 15798374, upload-time = "2026-05-18T23:37:02.674Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b0/413077f6b1153ed3cba361401c6783bbad6114804a000cc22eb71c13e190/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ad03c0965fb3c692200e74d458ca28c1dbb4ce96f9a479a8aa041ad5fabca02", size = 16747286, upload-time = "2026-05-18T23:37:06.327Z" }, + { url = "https://files.pythonhosted.org/packages/15/ce/e5ec180bc41812edcd8daeb8639d205622c0e8c02259d8ab25a0201b3c2a/numpy-2.4.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:2803abfebfc990042cd494d8ce2d5f82e9d847af6d35ec486923aa19dbad5e73", size = 12504263, upload-time = "2026-05-18T23:37:09.715Z" }, +] + +[[package]] +name = "numpy" +version = "2.5.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", +] +sdist = { url = "https://files.pythonhosted.org/packages/e7/05/3d27272d30698dc0ecb7fdfaa41ad70303b444f81722bb99bce1d818638a/numpy-2.5.0.tar.gz", hash = "sha256:5a129578019311b6e56bdd714250f19b518f7dceeeb8d1af5490f4942d3f891c", size = 20652461, upload-time = "2026-06-21T20:57:51.95Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/0a/11486d02add7b1384dff7374d124b1cfbb0ee864dcc9f6a2c0380638cf84/numpy-2.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:489780423903667933b4ed6197b6ec3b75ea5dd17d1d8f0f38d798feb6921561", size = 16789987, upload-time = "2026-06-21T20:56:16.657Z" }, + { url = "https://files.pythonhosted.org/packages/55/b2/285f48640a181947b4587a3766d21ec1eaa7fea833d4b49957e09da467a2/numpy-2.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ece55976ced6bca95a03ae2839e2e5ccffe8eb6a3e7022415645eb154a81e4e6", size = 11760322, upload-time = "2026-06-21T20:56:19.813Z" }, + { url = "https://files.pythonhosted.org/packages/dd/67/b032db1eb03ca30d16eda3b0c22aaa615338b9263c2fd559d0f29451aca4/numpy-2.5.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:c83b664b0e6eee9594fa920cf0639d8af796606d3fad6cc70180c87e4b97c7be", size = 5319605, upload-time = "2026-06-21T20:56:22.173Z" }, + { url = "https://files.pythonhosted.org/packages/b9/83/03fc7300c7c6b6c84c487b1dc80d322817b95fbd1f4dd57a85e23b7198de/numpy-2.5.0-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:bf80333980bf37f523341ddd72c783f39d6829ec7736b9eb99086388a2d52cc2", size = 6653628, upload-time = "2026-06-21T20:56:23.914Z" }, + { url = "https://files.pythonhosted.org/packages/82/49/2ec21730bc63ccfda829323f7040a8ed4715b3852ce658689cf74ee96a8c/numpy-2.5.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a1a4874217b36d5ac8fc876f52e39df56f8182c88463e9e2dceabf7ca8b7efb8", size = 15153691, upload-time = "2026-06-21T20:56:25.631Z" }, + { url = "https://files.pythonhosted.org/packages/bb/6b/f4a3d0637692c49da8ef99d72d52526f92e0a8d6ac4f0ca9f31441b9d9ea/numpy-2.5.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aaa760137137e8d3c920d27927748215b56014f92667dc9b6c27dfc61249255a", size = 16660066, upload-time = "2026-06-21T20:56:28.009Z" }, + { url = "https://files.pythonhosted.org/packages/3a/2f/c354ec86d1f3f5c19649463b0d39652e160736e5b0a4cd18dff0576715c4/numpy-2.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7174ce8265fc7f7417d171c9ea8fe905220748893ea67a2a7abe726ec331c4b0", size = 16514638, upload-time = "2026-06-21T20:56:30.26Z" }, + { url = "https://files.pythonhosted.org/packages/06/34/43efdcb319988648580f93c11f1ae82cf7e2faa74925e98e454ae3aa95f8/numpy-2.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b8c3daaf99de52415d20b42f8e8155c78642cb04207d02f9d317a0dcf1b3fb54", size = 18419647, upload-time = "2026-06-21T20:56:32.41Z" }, + { url = "https://files.pythonhosted.org/packages/71/e2/f5d1676b1d7fb682eb5e9a1641e7ebd2414b3216c370661d1029778908b4/numpy-2.5.0-cp312-cp312-win32.whl", hash = "sha256:6206db0af545d73d068add6d992279145f158428d1da6cc49adc4b630c5d6ee5", size = 6056688, upload-time = "2026-06-21T20:56:34.657Z" }, + { url = "https://files.pythonhosted.org/packages/8f/7c/48f115d1c58a34032facebcd51fdf2d02df2c51d4a46a81dd1197bb2ea6b/numpy-2.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:6f2d6873e2940c860a309d21e25b1e69af6aaffdd80aa056b04c16380db1c4f2", size = 12419237, upload-time = "2026-06-21T20:56:36.24Z" }, + { url = "https://files.pythonhosted.org/packages/86/26/2e0882f4044d1b1a1b63e875151fb2393389032022a8b7f5657a7996d3b2/numpy-2.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:a55e1eb2bca2cfd17a16b213c99dfc8502d47b0d494224d2122277d0400935ca", size = 10339912, upload-time = "2026-06-21T20:56:38.733Z" }, + { url = "https://files.pythonhosted.org/packages/8a/33/07675aaad7f26ea013d5e884d9a0d784b79c6bd7566c333f5a52fa3c610b/numpy-2.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:520e6b8be0a4b65840ac8090d4f51cef4bed66e2b0894d5a520f099adc24a9b2", size = 16784890, upload-time = "2026-06-21T20:56:40.799Z" }, + { url = "https://files.pythonhosted.org/packages/85/4b/953118a730ee3b35e28645e0eb4cf9beec5bdbb954e1ac2f5fcefba6bbc3/numpy-2.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:146b81cdd3967fdb6beca8ba25f00c58741d8f3cbd797f55af0fbe0bfec3469c", size = 11754584, upload-time = "2026-06-21T20:56:43.094Z" }, + { url = "https://files.pythonhosted.org/packages/44/9b/56dd530c367c74ae17411027cea4135ca57e1e0583bf5594cee18bd83217/numpy-2.5.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:126b88d95e8ff9b00c9e717aa540469f21d6180162f84c0caec51b16215d49cd", size = 5313904, upload-time = "2026-06-21T20:56:45.503Z" }, + { url = "https://files.pythonhosted.org/packages/ce/b0/bcd672edad27ecca7da1f7bb0ce72cd1706a4f2d79ae94990afc97c13e1c/numpy-2.5.0-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:d4313cef1594c5ce46c31b6e54e918338f63f16ee9322304e8c9114d6d81c8bd", size = 6648504, upload-time = "2026-06-21T20:56:47.567Z" }, + { url = "https://files.pythonhosted.org/packages/80/9e/15cdfcbd30a1544a46c9e487a00df331c4672450216538705a9e51fa6710/numpy-2.5.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:750fb097caf26fa878746d9d119f6f9da12dedcbff1eea966c3e3447647c4a9e", size = 15150086, upload-time = "2026-06-21T20:56:49.352Z" }, + { url = "https://files.pythonhosted.org/packages/32/4e/8d7656ccaab3e81e97258b8a9bc5f0c8502513a92fb4ceb0a2cbfebc17bf/numpy-2.5.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3893adc2dc7c0412ba76777db55a049215d99c9aa3113003be8f49f4f1290ab9", size = 16647250, upload-time = "2026-06-21T20:56:51.542Z" }, + { url = "https://files.pythonhosted.org/packages/3c/81/97060281b602ed07f21b12f4ec409eac1f75a2f91fbc829ed8b2becf3ad4/numpy-2.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:835e454dd99b238cdc5a3f63bce2371296f5ebc53ca1e0f8e6ddbb6d92a29aab", size = 16512864, upload-time = "2026-06-21T20:56:55.401Z" }, + { url = "https://files.pythonhosted.org/packages/33/ab/4496208146911f8d8ddb54f68a972aafa6c8d44babcb2ea03b0e5cc87c9d/numpy-2.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6f9836778081a0a3c02a6a21493f3e9f5b311f8d2541934f31f05583dc999ea4", size = 18408407, upload-time = "2026-06-21T20:56:57.75Z" }, + { url = "https://files.pythonhosted.org/packages/d4/9f/a4df67c181e4ee8b467aa3332dc2db10fd5c515136831302f3ca48bc0a01/numpy-2.5.0-cp313-cp313-win32.whl", hash = "sha256:0b525be4744b60bb0557ac872d53ef07d085b5f39622bc579c98d3809d05b988", size = 6054431, upload-time = "2026-06-21T20:57:00.016Z" }, + { url = "https://files.pythonhosted.org/packages/30/53/491e1c47c55b62ccc6a63c1c5b8635c73fc2258dddeb9bda27cae4a0ae96/numpy-2.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:44353e2878930039db472b99dc353d749826e4010bd4d2a7f835e94a97a5c748", size = 12414420, upload-time = "2026-06-21T20:57:01.815Z" }, + { url = "https://files.pythonhosted.org/packages/eb/4a/25c2906f541e9d9f4c5769764db732e6627be91a13f4724fa10634d77db4/numpy-2.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:48f54b00711f83a5f796b70c518e8c2b3c5848dda03a54911f23eb68519b9b60", size = 10339533, upload-time = "2026-06-21T20:57:03.961Z" }, + { url = "https://files.pythonhosted.org/packages/86/ad/abc44aaceaf7b17ee1edde2bbb4458da591bc79574cffff50c4bb35f00d1/numpy-2.5.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f27582c55ba4c750b7c58c8faf021d2cd9324a662b466229db8a417b41368af9", size = 16783807, upload-time = "2026-06-21T20:57:06.253Z" }, + { url = "https://files.pythonhosted.org/packages/5d/39/b72e168daf9c00fb20c9fc996d00437ccecdef3102387775d29d7a62576d/numpy-2.5.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:28e7137057d551e4a83c4ae414e3451f50568409db7569aacc7f9811ee06a446", size = 11765215, upload-time = "2026-06-21T20:57:08.547Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a0/8400a9c0e3625182347593f5e1f57da9a617a534794805c8df5518154ddc/numpy-2.5.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:e1da54b53e75cd9fcfc23efcc7edab2c6aecf97b6037566d8a0fe804af8ec57c", size = 5324493, upload-time = "2026-06-21T20:57:11.012Z" }, + { url = "https://files.pythonhosted.org/packages/f6/8c/0d104deaa0401c93395a629ec902891618a2eff76d19229139cb5a887bfc/numpy-2.5.0-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:694d8f74e156f7fd01179f1aa8faa2f648ab6ae0f70b6c3fe57a03249aea2303", size = 6645211, upload-time = "2026-06-21T20:57:12.919Z" }, + { url = "https://files.pythonhosted.org/packages/6a/d9/4a4a628c812750363786afc3d33492709a5cd64b215469c16b0f6c7bb811/numpy-2.5.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1a7569a7b53c77716f036bb28cb1c91f166a26ec7d9502cd1e4bdfe502fdec22", size = 15166004, upload-time = "2026-06-21T20:57:14.717Z" }, + { url = "https://files.pythonhosted.org/packages/a0/5e/2a902317d7fc4aa93236e80c932662dadfc459b323d758329e01775125e1/numpy-2.5.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:39a0433bd4086ebd462960cf375e19195bb07b53dc1d87dd5fcf47ad78576f03", size = 16650797, upload-time = "2026-06-21T20:57:16.906Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a0/a0090e6329f4ca5992c07847bb579c5259a19953dc57255bb08793142ffb/numpy-2.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:929f0c79ac38bcbd7154fe631dc907abfeddbcc5027a896bd1f7767323271e7a", size = 16524647, upload-time = "2026-06-21T20:57:19.165Z" }, + { url = "https://files.pythonhosted.org/packages/5e/7d/6caf27734c42b65837e7461ed0dbbd6b6fc835060c9714ec59d673bb383a/numpy-2.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cc4f247a47bbf070bfd70be53ccdcf47b800af563535e7bbe172322197c30e21", size = 18411841, upload-time = "2026-06-21T20:57:21.638Z" }, + { url = "https://files.pythonhosted.org/packages/13/dc/26edadbd812536769a82c2e9e002234e33feb5da43061d47a044f6d309b7/numpy-2.5.0-cp314-cp314-win32.whl", hash = "sha256:5dc71423499fab3f46f7a7201155ade1669ea101f2f429d332df9e72f8161731", size = 6106361, upload-time = "2026-06-21T20:57:23.844Z" }, + { url = "https://files.pythonhosted.org/packages/f2/9e/4dd1459282229a72d92dece2ae9138e5cac94a72263a7ceb48f37434c925/numpy-2.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:ebb81d9d5443e0309d6c54894c3fbed74ad7da0714352a67b6d773cd189eae73", size = 12551749, upload-time = "2026-06-21T20:57:25.945Z" }, + { url = "https://files.pythonhosted.org/packages/05/a7/6bc6384c080b86c7f6c85c5bc5b540b24f4f679cd144791d99574e90d462/numpy-2.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:3b94d0d0deceebfad3e67ae5c0e5eb87371e8f7a0581cd04a779928c2450cf1e", size = 10617072, upload-time = "2026-06-21T20:57:28.175Z" }, + { url = "https://files.pythonhosted.org/packages/86/6b/4a2b71d66ada5608ae02b63f150dfad520f6940721cb7f029ad270befc0e/numpy-2.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:22f3d43e362d650bc39db1f17851302874a148ca95ba6981c1dfb5fa6862f35b", size = 11881067, upload-time = "2026-06-21T20:57:30.104Z" }, + { url = "https://files.pythonhosted.org/packages/dc/b2/d365eb40a20efb49d67e9feb90494ed8511282ee1f5fa16006675c65397d/numpy-2.5.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:243563efb4cd7528a264567e9fd206c87826457322521d06206a00bfa316c927", size = 5440290, upload-time = "2026-06-21T20:57:32.193Z" }, + { url = "https://files.pythonhosted.org/packages/fa/5e/e9c03188de5f9b767e46a8fe988bcfd3efad066a4a3fda8b9cb11a93f895/numpy-2.5.0-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:84881d825ca75249b189bbee875fcfe3238aa5c479e6100893cda566e8e86826", size = 6748371, upload-time = "2026-06-21T20:57:33.933Z" }, + { url = "https://files.pythonhosted.org/packages/fd/1d/68c186a38a5027bae2c4ddd5ea681fdaf8b4d30fb7301def6d8ad270390f/numpy-2.5.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cda12aa4779d42b8771180aba759c96f527d43446d8f380ab59e2b35e8489efd", size = 15214643, upload-time = "2026-06-21T20:57:35.677Z" }, + { url = "https://files.pythonhosted.org/packages/8c/67/73f67b7c7e20635baae9c4c3ead4ae7326a005900297a6110971abd62eb5/numpy-2.5.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c0121101093d2bd74981b10f8837d78e794a8ff57834eb27179f49e1ba11ac6", size = 16690128, upload-time = "2026-06-21T20:57:38.159Z" }, + { url = "https://files.pythonhosted.org/packages/eb/05/d4c1fb0c46d02a27d6b2b8b319a78c90937acec8631c1641874670b31e6f/numpy-2.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d371c92cfa09da00022f501ab67fafaea813d752eb30ac44336d45b1e5b0268a", size = 16577902, upload-time = "2026-06-21T20:57:40.447Z" }, + { url = "https://files.pythonhosted.org/packages/9e/1d/771c797d50fa26e4888989cccf1d50ee51f530d4e455ad2692dcb64fa711/numpy-2.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9990713e9c38154c6861e7547f1e3fc7a87e75ff09bab24ef1cc81d81c2835e9", size = 18452814, upload-time = "2026-06-21T20:57:42.875Z" }, + { url = "https://files.pythonhosted.org/packages/e8/46/52fc0d2a68d7643f0f149eeea5a5d8ea2a3507056ac8afa83c9212606e8b/numpy-2.5.0-cp314-cp314t-win32.whl", hash = "sha256:edadfbd4794b1086c0d822f81863e8a68fc129d132fd0bb9e31e955d7fbbbdb7", size = 6253168, upload-time = "2026-06-21T20:57:45.101Z" }, + { url = "https://files.pythonhosted.org/packages/2a/be/6c8d1118b5f13b2881dc095d5b345de19c6638b8959c17409b6eff84c8aa/numpy-2.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f7e5fa4382967ae6548bd2f174219afb908e294b0d5f625af01166edd5f7d9aa", size = 12736286, upload-time = "2026-06-21T20:57:46.935Z" }, + { url = "https://files.pythonhosted.org/packages/fd/6a/d3a169aaf8536cf228d56a09e04bcb713a2fe4410d4e2105b9419b5a9c89/numpy-2.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:016623417bb330d719d579daf2d6b9a01ddc52e41a9ed61a47f39fde46dcd865", size = 10686451, upload-time = "2026-06-21T20:57:49.313Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pyopengl" +version = "3.1.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/16/912b7225d56284859cd9a672827f18be43f8012f8b7b932bc4bd959a298e/pyopengl-3.1.10.tar.gz", hash = "sha256:c4a02d6866b54eb119c8e9b3fb04fa835a95ab802dd96607ab4cdb0012df8335", size = 1915580, upload-time = "2025-08-18T02:33:01.76Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/e4/1ba6f44e491c4eece978685230dde56b14d51a0365bc1b774ddaa94d14cd/pyopengl-3.1.10-py3-none-any.whl", hash = "sha256:794a943daced39300879e4e47bd94525280685f42dbb5a998d336cfff151d74f", size = 3194996, upload-time = "2025-08-18T02:32:59.902Z" }, +] + +[[package]] +name = "pyqtgraph" +version = "0.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/36/4c242f81fdcbfa4fb62a5645f6af79191f4097a0577bd5460c24f19cc4ef/pyqtgraph-0.14.0-py3-none-any.whl", hash = "sha256:7abb7c3e17362add64f8711b474dffac5e7b0e9245abdf992e9a44119b7aa4f5", size = 1924755, upload-time = "2025-11-16T19:43:22.251Z" }, +] + +[[package]] +name = "pyside6" +version = "6.11.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyside6-addons" }, + { name = "pyside6-essentials" }, + { name = "shiboken6" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/a6/27ba5947ed48918f7b74b7c43a1e280aac069e36f25adeb4c9adfac835c4/pyside6-6.11.1-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:537682c3b7530817203e667c1f5a2f00486b37bf52c52eeab438544c7a0917f6", size = 571921, upload-time = "2026-05-13T09:47:36.402Z" }, + { url = "https://files.pythonhosted.org/packages/d8/de/af89d71410c83b10654d86ff9aff2a4f87c30163658f1cc145242e222526/pyside6-6.11.1-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:b1fc521ba2bb5109425ab8add06bddbdd524abcad06cfa012cc39a22a189feb2", size = 572102, upload-time = "2026-05-13T09:47:38.249Z" }, + { url = "https://files.pythonhosted.org/packages/b6/0e/d583bd3f7bf5046a4497b36f3902cfb64aa29554489a5a25c18e6b4ac0ac/pyside6-6.11.1-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:75f0005c3eb95c07cfb65522ec50d0815ac007a96482c21dc3cb4b4c04895d84", size = 572098, upload-time = "2026-05-13T09:47:39.44Z" }, + { url = "https://files.pythonhosted.org/packages/57/f2/d9d8ce1373dabb37e5919f63cd18446556079631d3f2eea3ada03c29f6b8/pyside6-6.11.1-cp310-abi3-win_amd64.whl", hash = "sha256:0968877ab1fb4ef3587a284da6fe05e8647ada56a6a3750b6395188e01f4aba6", size = 578377, upload-time = "2026-05-13T09:47:40.76Z" }, + { url = "https://files.pythonhosted.org/packages/96/02/a6057d8bd2bdb1940820fff2d627fdf4013148c9c57adf69fa40d3452ac3/pyside6-6.11.1-cp310-abi3-win_arm64.whl", hash = "sha256:acee467cb5f256cc47ebb9d815a054c1d8416da380c191b247a76d164aa3f805", size = 561765, upload-time = "2026-05-13T09:47:41.9Z" }, +] + +[[package]] +name = "pyside6-addons" +version = "6.11.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyside6-essentials" }, + { name = "shiboken6" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/6b/8bc94aff48b63f788f2d84e5467c12362d68906ba742c0942f46cb04c879/pyside6_addons-6.11.1-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:54733c77f789bef5f03c6aff4ad3bec8b2eff021f0cfcbc53d5e6c250ded24f9", size = 331714589, upload-time = "2026-05-13T09:39:12.36Z" }, + { url = "https://files.pythonhosted.org/packages/dd/62/fb1428a523b2a4541e232aab50d9e789e6b4526f37fd9593452a7ea5b6b3/pyside6_addons-6.11.1-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:8e6c65fbd73a512d6f72cda8d8277444a85a34dc99dd1dae9c21d35b8671bb1f", size = 175063224, upload-time = "2026-05-13T09:39:34.185Z" }, + { url = "https://files.pythonhosted.org/packages/ee/9b/2ccd52f66db55c06de65d0501170a1935d04d64d0a230c0d892284a02ce3/pyside6_addons-6.11.1-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:bf1c6c4e954e5eba3d2a7c661ad4b9689e8f09c7f4a16bdf29713371d11af993", size = 170553429, upload-time = "2026-05-13T09:39:54.424Z" }, + { url = "https://files.pythonhosted.org/packages/9a/bd/8adc4d350b3b363f3dfc8fccdcf5bfed25f7e36c2fff30c64e106f4f1572/pyside6_addons-6.11.1-cp310-abi3-win_amd64.whl", hash = "sha256:0d13c4dfd671b050a48e4f8d8ddc724b7248f9c0437e7fc47fdf316278572923", size = 168816308, upload-time = "2026-05-13T09:40:13.541Z" }, + { url = "https://files.pythonhosted.org/packages/65/b7/9a840d97f0f0f04e372a87e205dd30ee285b4e3b021b188459a917c9dc76/pyside6_addons-6.11.1-cp310-abi3-win_arm64.whl", hash = "sha256:3494f480dee92f415be2f2d989c0b3f4755ac332b28045cbf4ba0f5c5a22ba37", size = 35759347, upload-time = "2026-05-13T09:40:21.199Z" }, +] + +[[package]] +name = "pyside6-essentials" +version = "6.11.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "shiboken6" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/da/10d9197e7370eb4fed8df5fc547b7548dec88e5c5949e2d450db4ae96feb/pyside6_essentials-6.11.1-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:228de53c2bc26b07e5021fbe3614fc44ca08e4dab9999af08c2b389d2c239957", size = 110352945, upload-time = "2026-05-13T09:43:08.006Z" }, + { url = "https://files.pythonhosted.org/packages/5c/49/0e1237c4400bec7e335d2c4eeb49bc40d9fd88a9ac44ca9083ce1abdc308/pyside6_essentials-6.11.1-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:e3ef7027b41e4e55fadb56e3b3257dc8ee92154b639fe67fc4c8e05e9d976c60", size = 79908535, upload-time = "2026-05-13T09:43:24.836Z" }, + { url = "https://files.pythonhosted.org/packages/4c/c5/da4c5f23c6540ac5211a1f60177c8dee84b1bf40f2719479587ab8c60731/pyside6_essentials-6.11.1-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:a039b6da68a3a4b9d243217b2b98d475eed3f617159ef6be925badab53c11b0d", size = 78960051, upload-time = "2026-05-13T09:43:35.423Z" }, + { url = "https://files.pythonhosted.org/packages/64/0e/b663ecc96ca57b5c91b83b6615d6b174380b0faf30338125c26e053d6aa7/pyside6_essentials-6.11.1-cp310-abi3-win_amd64.whl", hash = "sha256:63311bd48e32c584599ab04b9ef7c324082374cd2c9fa533f978fb893bb47e40", size = 77549267, upload-time = "2026-05-13T09:43:44.92Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/eb6723faf5cb7fa581145da1c15f40d641b96e080f0491af2f1859fdeedb/pyside6_essentials-6.11.1-cp310-abi3-win_arm64.whl", hash = "sha256:11253ea52aabecefe9febddbbe78b43a824129e3af1cec98431028fba7fa954f", size = 57964512, upload-time = "2026-05-13T09:43:52.968Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "shiboken6" +version = "6.11.1" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/f3/f2b63df0251e7cd3172ea28e32ede52739de9566bcefcd0178681538ac81/shiboken6-6.11.1-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:1a16867f103ef1c662a5f09dfed03273a9f81688b174555162c58e83650a3f02", size = 476874, upload-time = "2026-05-13T09:47:01.091Z" }, + { url = "https://files.pythonhosted.org/packages/c7/9b/e0355d8897b5c150770f1d95718aad17d432fcc9c035c04f3f58427d4693/shiboken6-6.11.1-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9a8bccfafc8805254cabcfa1edfaf55cd52889f4998c91ad0d9a4433fb1bcdbe", size = 272222, upload-time = "2026-05-13T09:47:02.653Z" }, + { url = "https://files.pythonhosted.org/packages/57/d5/dd4f1defed400be03340f2ede34b61f846776650b4e7ed9ebaf4c71979a2/shiboken6-6.11.1-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:1bd2f4314414df2d122d9f646e03b731bc6d6b5f77a5f53f99a4fe4e97d84e6f", size = 270350, upload-time = "2026-05-13T09:47:04.02Z" }, + { url = "https://files.pythonhosted.org/packages/52/b5/3f6fb2ee65b534193fb4ef713dd619dc31dadff5d12c16979a7699ad58be/shiboken6-6.11.1-cp310-abi3-win_amd64.whl", hash = "sha256:c2c6863aa80ec18c0f82cea3417837b279cdc60024ac17123461dc9042577df7", size = 1223647, upload-time = "2026-05-13T09:47:05.924Z" }, + { url = "https://files.pythonhosted.org/packages/98/d1/f15ca0e1666faae02c945f48e745ea35f8fcd8243b176109b4e2c4251f47/shiboken6-6.11.1-cp310-abi3-win_arm64.whl", hash = "sha256:7c8d9af17db4495d4fa5b1c393f218311c4855546b9dfa6a0bd21bcd66b55e9d", size = 1784170, upload-time = "2026-05-13T09:47:07.617Z" }, +] + +[[package]] +name = "splasher" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "pyopengl" }, + { name = "pyqtgraph" }, + { name = "pyside6" }, +] + +[package.optional-dependencies] +apairo = [ + { name = "apairo" }, + { name = "pyyaml" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, +] + +[package.metadata] +requires-dist = [ + { name = "apairo", marker = "extra == 'apairo'", git = "https://github.com/apairo-robotics/apairo.git" }, + { name = "numpy" }, + { name = "pyopengl" }, + { name = "pyqtgraph" }, + { name = "pyside6" }, + { name = "pyyaml", marker = "extra == 'apairo'" }, +] +provides-extras = ["apairo"] + +[package.metadata.requires-dev] +dev = [{ name = "pytest" }]