Commit ·
0483bf4
0
Parent(s):
Splasher V1.0
Browse filesSplasher is a labeled for 3d point cloud to a 2d grid!
This view is limited to 50 files because it contains too many changes. See raw diff
- README.md +51 -0
- examples/demo_arraysource.py +10 -0
- pyproject.toml +36 -0
- splasher/__init__.py +33 -0
- splasher/__pycache__/__init__.cpython-312.pyc +0 -0
- splasher/__pycache__/cli.cpython-312.pyc +0 -0
- splasher/__pycache__/demo.cpython-312.pyc +0 -0
- splasher/adapters/__init__.py +1 -0
- splasher/adapters/__pycache__/__init__.cpython-312.pyc +0 -0
- splasher/adapters/__pycache__/apairo_source.cpython-312.pyc +0 -0
- splasher/adapters/apairo_source.py +72 -0
- splasher/cli.py +38 -0
- splasher/core/__init__.py +1 -0
- splasher/core/__pycache__/__init__.cpython-312.pyc +0 -0
- splasher/core/__pycache__/accumulate.cpython-312.pyc +0 -0
- splasher/core/__pycache__/array_source.cpython-312.pyc +0 -0
- splasher/core/__pycache__/colormap.cpython-312.pyc +0 -0
- splasher/core/__pycache__/grid.cpython-312.pyc +0 -0
- splasher/core/__pycache__/io.cpython-312.pyc +0 -0
- splasher/core/__pycache__/labels.cpython-312.pyc +0 -0
- splasher/core/__pycache__/poses.cpython-312.pyc +0 -0
- splasher/core/__pycache__/projection.cpython-312.pyc +0 -0
- splasher/core/__pycache__/source.cpython-312.pyc +0 -0
- splasher/core/__pycache__/target.cpython-312.pyc +0 -0
- splasher/core/accumulate.py +91 -0
- splasher/core/array_source.py +55 -0
- splasher/core/colormap.py +51 -0
- splasher/core/grid.py +111 -0
- splasher/core/io.py +101 -0
- splasher/core/labels.py +96 -0
- splasher/core/poses.py +60 -0
- splasher/core/projection.py +86 -0
- splasher/core/source.py +75 -0
- splasher/core/target.py +169 -0
- splasher/demo.py +95 -0
- splasher/ui/__init__.py +1 -0
- splasher/ui/__pycache__/__init__.cpython-312.pyc +0 -0
- splasher/ui/__pycache__/channel_manager.cpython-312.pyc +0 -0
- splasher/ui/__pycache__/cloud_view.cpython-312.pyc +0 -0
- splasher/ui/__pycache__/grid_designer.cpython-312.pyc +0 -0
- splasher/ui/__pycache__/grid_view.cpython-312.pyc +0 -0
- splasher/ui/__pycache__/image_view.cpython-312.pyc +0 -0
- splasher/ui/__pycache__/main_window.cpython-312.pyc +0 -0
- splasher/ui/__pycache__/palette.cpython-312.pyc +0 -0
- splasher/ui/__pycache__/timeline.cpython-312.pyc +0 -0
- splasher/ui/app.py +33 -0
- splasher/ui/channel_manager.py +55 -0
- splasher/ui/cloud_view.py +52 -0
- splasher/ui/grid_designer.py +103 -0
- splasher/ui/grid_view.py +124 -0
README.md
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Splasher
|
| 2 |
+
|
| 3 |
+
Outil de **labélisation** dont le cœur est générique : on lui donne un *dataset
|
| 4 |
+
synchrone* — à chaque instant, un **pack de canaux nommés** (nuage de points 3D,
|
| 5 |
+
image caméra, pose, …) — et on labélise soit une **grille 2D vue-de-dessus (BEV)**,
|
| 6 |
+
soit directement les **points 3D**, soit les deux.
|
| 7 |
+
|
| 8 |
+
Premier cas d'usage : la **traversabilité**. Mais rien n'est câblé en dur : pas de
|
| 9 |
+
schéma de classes imposé, pas de sémantique monde imposée, et **aucune dépendance
|
| 10 |
+
obligatoire à un format de dataset**. apairo n'est qu'un adaptateur d'entrée optionnel.
|
| 11 |
+
|
| 12 |
+
## Idée
|
| 13 |
+
|
| 14 |
+
- Plusieurs canaux synchronisés, affichés comme références : on se balade librement
|
| 15 |
+
dans le nuage 3D, on regarde les images caméra.
|
| 16 |
+
- On choisit les **canaux** à afficher (dock *Canaux* : montrer/masquer chaque nuage
|
| 17 |
+
ou caméra disponible dans la source — plusieurs caméras et nuages possibles).
|
| 18 |
+
- On **dessine la grille de carrés** (vue de dessus) : son étendue et la taille de
|
| 19 |
+
chaque carré, créée explicitement via **« Nouvelle grille »**. L'annulation (undo)
|
| 20 |
+
est **par frame**.
|
| 21 |
+
- On **sélectionne un rectangle** à la souris sur cette vue de dessus. Selon la cible :
|
| 22 |
+
- **Grid** : remplit les carrés couverts de la classe active (sortie = raster d'IDs).
|
| 23 |
+
- **Points** : assigne la classe aux points 3D dans le rectangle (sortie = labels par point).
|
| 24 |
+
- Mode **Sélection** (façon bureau) : tracer un rectangle sélectionne des cellules
|
| 25 |
+
(**Shift** = ajouter, sélections non contiguës possibles), puis on **applique** la classe
|
| 26 |
+
à toute la sélection d'un coup. Changer de grille demande **confirmation** si une
|
| 27 |
+
labélisation existe déjà.
|
| 28 |
+
- **Cumul** : on peut cumuler ±N frames **recalées par leurs poses** dans le repère du
|
| 29 |
+
frame courant (nuage plus dense pour mieux labéliser). La grille et les labels restent
|
| 30 |
+
**par frame** : un coup de pinceau sur le nuage cumulé est **décumulé** vers chaque
|
| 31 |
+
frame source. (Nécessite un canal `POSE`.)
|
| 32 |
+
|
| 33 |
+
## Installation
|
| 34 |
+
|
| 35 |
+
```bash
|
| 36 |
+
cd ~/dev/splasher
|
| 37 |
+
uv sync # cœur seul (numpy + Qt)
|
| 38 |
+
uv sync --extra apairo # + adaptateur apairo (optionnel)
|
| 39 |
+
```
|
| 40 |
+
|
| 41 |
+
## Démo (zéro donnée externe)
|
| 42 |
+
|
| 43 |
+
```bash
|
| 44 |
+
uv run python examples/demo_arraysource.py
|
| 45 |
+
```
|
| 46 |
+
|
| 47 |
+
## Entrée
|
| 48 |
+
|
| 49 |
+
Le cœur consomme une `Source` : `__len__`, `__getitem__(i) -> Frame`, `channels()`.
|
| 50 |
+
`ArraySource` en construit une depuis des tableaux numpy en mémoire. `ApairoSource`
|
| 51 |
+
(extra `apairo`) enveloppe tout dataset apairo synchrone.
|
examples/demo_arraysource.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Lance Splasher sur une source synthétique — aucune donnée externe requise.
|
| 2 |
+
|
| 3 |
+
uv run python examples/demo_arraysource.py
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from splasher import launch
|
| 7 |
+
from splasher.demo import make_demo_source
|
| 8 |
+
|
| 9 |
+
if __name__ == "__main__":
|
| 10 |
+
launch(make_demo_source(), title="Splasher — démo")
|
pyproject.toml
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[project]
|
| 2 |
+
name = "splasher"
|
| 3 |
+
version = "0.1.0"
|
| 4 |
+
description = "Labélisation : canaux synchronisés (nuages 3D, images) -> grille 2D BEV ou labels par point"
|
| 5 |
+
readme = "README.md"
|
| 6 |
+
authors = [{ name = "Augustin Bresset", email = "augustin.bresset@gmail.com" }]
|
| 7 |
+
requires-python = ">=3.11"
|
| 8 |
+
dependencies = [
|
| 9 |
+
"numpy",
|
| 10 |
+
"pyqtgraph",
|
| 11 |
+
"PySide6",
|
| 12 |
+
"PyOpenGL",
|
| 13 |
+
]
|
| 14 |
+
|
| 15 |
+
# apairo n'est qu'UN adaptateur d'entrée optionnel — le cœur n'en dépend pas.
|
| 16 |
+
[project.optional-dependencies]
|
| 17 |
+
apairo = [
|
| 18 |
+
"apairo @ git+https://github.com/apairo-robotics/apairo.git",
|
| 19 |
+
"PyYAML",
|
| 20 |
+
]
|
| 21 |
+
|
| 22 |
+
[project.scripts]
|
| 23 |
+
splasher = "splasher.cli:main"
|
| 24 |
+
|
| 25 |
+
[dependency-groups]
|
| 26 |
+
dev = ["pytest"]
|
| 27 |
+
|
| 28 |
+
[build-system]
|
| 29 |
+
requires = ["hatchling"]
|
| 30 |
+
build-backend = "hatchling.build"
|
| 31 |
+
|
| 32 |
+
[tool.hatch.build.targets.wheel]
|
| 33 |
+
packages = ["splasher"]
|
| 34 |
+
|
| 35 |
+
[tool.hatch.metadata]
|
| 36 |
+
allow-direct-references = true
|
splasher/__init__.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Splasher — labélisation de canaux synchronisés vers une grille 2D BEV ou des labels par point.
|
| 2 |
+
|
| 3 |
+
Le cœur (`splasher.core`) ne dépend que de numpy et reste importable sans toolkit UI.
|
| 4 |
+
L'UI (PySide6/pyqtgraph) n'est chargée qu'au moment de `launch()`.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
from .core.source import ChannelKind, ChannelSpec, Frame, Source, channels_of_kind
|
| 10 |
+
from .core.array_source import ArraySource
|
| 11 |
+
from .core.grid import Grid, grid_from_points
|
| 12 |
+
|
| 13 |
+
__version__ = "0.1.0"
|
| 14 |
+
|
| 15 |
+
__all__ = [
|
| 16 |
+
"ChannelKind",
|
| 17 |
+
"ChannelSpec",
|
| 18 |
+
"Frame",
|
| 19 |
+
"Source",
|
| 20 |
+
"channels_of_kind",
|
| 21 |
+
"ArraySource",
|
| 22 |
+
"Grid",
|
| 23 |
+
"grid_from_points",
|
| 24 |
+
"launch",
|
| 25 |
+
"__version__",
|
| 26 |
+
]
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def launch(*args, **kwargs):
|
| 30 |
+
"""Ouvre la fenêtre Splasher sur une `Source`. Importe l'UI paresseusement."""
|
| 31 |
+
from .ui.app import launch as _launch
|
| 32 |
+
|
| 33 |
+
return _launch(*args, **kwargs)
|
splasher/__pycache__/__init__.cpython-312.pyc
ADDED
|
Binary file (1.1 kB). View file
|
|
|
splasher/__pycache__/cli.cpython-312.pyc
ADDED
|
Binary file (1.8 kB). View file
|
|
|
splasher/__pycache__/demo.cpython-312.pyc
ADDED
|
Binary file (5.86 kB). View file
|
|
|
splasher/adapters/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Adaptateurs d'entrée optionnels (apairo, …). Aucun n'est requis par le cœur."""
|
splasher/adapters/__pycache__/__init__.cpython-312.pyc
ADDED
|
Binary file (252 Bytes). View file
|
|
|
splasher/adapters/__pycache__/apairo_source.cpython-312.pyc
ADDED
|
Binary file (4.79 kB). View file
|
|
|
splasher/adapters/apairo_source.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Adaptateur **optionnel** : un dataset apairo synchrone -> `Source` Splasher.
|
| 2 |
+
|
| 3 |
+
apairo n'est pas importé au niveau module : seul `from_path` le charge. La classe
|
| 4 |
+
fonctionne en duck-typing sur n'importe quel objet façon apairo
|
| 5 |
+
(`is_synchronous`, `keys`, `__len__`, `__getitem__` -> objet avec `.data`/`.timestamp`).
|
| 6 |
+
Installer via l'extra : `uv sync --extra apairo`.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import numpy as np
|
| 12 |
+
|
| 13 |
+
from ..core.source import ChannelKind, ChannelSpec, Frame
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def _kind_of(arr: np.ndarray) -> ChannelKind:
|
| 17 |
+
"""Devine le `ChannelKind` d'un canal apairo d'après la forme du tableau."""
|
| 18 |
+
if arr.ndim == 3 and arr.shape[2] in (1, 3, 4):
|
| 19 |
+
return ChannelKind.IMAGE
|
| 20 |
+
if arr.shape in ((4, 4), (3, 4)) or arr.shape == (7,):
|
| 21 |
+
return ChannelKind.POSE
|
| 22 |
+
if arr.ndim == 2 and arr.shape[1] >= 3:
|
| 23 |
+
return ChannelKind.POINTCLOUD
|
| 24 |
+
return ChannelKind.SCALAR # ex. labels (N,)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class ApairoSource:
|
| 28 |
+
"""Enveloppe un dataset apairo **synchrone** en `Source`."""
|
| 29 |
+
|
| 30 |
+
def __init__(self, dataset, keys: list[str] | None = None) -> None:
|
| 31 |
+
if not getattr(dataset, "is_synchronous", False):
|
| 32 |
+
raise ValueError(
|
| 33 |
+
"ApairoSource requiert un dataset apairo synchrone — "
|
| 34 |
+
"appelez ds.synchronize(reference=..., tolerance=...) d'abord."
|
| 35 |
+
)
|
| 36 |
+
self._ds = dataset
|
| 37 |
+
self._keys = list(keys) if keys is not None else list(dataset.keys)
|
| 38 |
+
self._specs = self._classify()
|
| 39 |
+
|
| 40 |
+
def _classify(self) -> list[ChannelSpec]:
|
| 41 |
+
sample = self._ds[0]
|
| 42 |
+
specs = []
|
| 43 |
+
for k in self._keys:
|
| 44 |
+
arr = np.asarray(sample.data[k])
|
| 45 |
+
specs.append(ChannelSpec(k, _kind_of(arr), arr.dtype, tuple(arr.shape)))
|
| 46 |
+
return specs
|
| 47 |
+
|
| 48 |
+
def __len__(self) -> int:
|
| 49 |
+
return len(self._ds)
|
| 50 |
+
|
| 51 |
+
def __getitem__(self, index: int) -> Frame:
|
| 52 |
+
s = self._ds[index]
|
| 53 |
+
channels = {k: np.asarray(s.data[k]) for k in self._keys if k in s.data}
|
| 54 |
+
return Frame(channels=channels, timestamp=getattr(s, "timestamp", None))
|
| 55 |
+
|
| 56 |
+
def channels(self) -> list[ChannelSpec]:
|
| 57 |
+
return list(self._specs)
|
| 58 |
+
|
| 59 |
+
@classmethod
|
| 60 |
+
def from_path(cls, path: str, *, keys: list[str] | None = None,
|
| 61 |
+
reference: str | None = None, tolerance: float = 0.1) -> "ApairoSource":
|
| 62 |
+
"""Ouvre un `RawDataset` apairo et le synchronise si besoin."""
|
| 63 |
+
import apairo # import paresseux — l'extra `apairo` doit être installé
|
| 64 |
+
|
| 65 |
+
ds = apairo.RawDataset(path, keys=keys) if keys else apairo.RawDataset(path)
|
| 66 |
+
if not ds.is_synchronous:
|
| 67 |
+
if reference is None:
|
| 68 |
+
raise ValueError(
|
| 69 |
+
"dataset asynchrone : précisez reference=<canal> pour la synchronisation."
|
| 70 |
+
)
|
| 71 |
+
ds = ds.synchronize(reference=reference, tolerance=tolerance)
|
| 72 |
+
return cls(ds, keys=keys)
|
splasher/cli.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""CLI Splasher.
|
| 2 |
+
|
| 3 |
+
splasher demo # source synthétique (zéro donnée externe)
|
| 4 |
+
splasher <chemin> --adapter apairo # via l'adaptateur apairo (extra optionnel)
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import argparse
|
| 10 |
+
import sys
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def main(argv: list[str] | None = None) -> int:
|
| 14 |
+
parser = argparse.ArgumentParser(prog="splasher", description=__doc__)
|
| 15 |
+
parser.add_argument("path", nargs="?", help="chemin du dataset, ou 'demo'")
|
| 16 |
+
parser.add_argument("--adapter", choices=["apairo"], help="adaptateur d'entrée")
|
| 17 |
+
args = parser.parse_args(argv)
|
| 18 |
+
|
| 19 |
+
from . import launch
|
| 20 |
+
|
| 21 |
+
if args.path in (None, "demo"):
|
| 22 |
+
from .demo import make_demo_source
|
| 23 |
+
|
| 24 |
+
launch(make_demo_source(), title="Splasher — démo")
|
| 25 |
+
return 0
|
| 26 |
+
|
| 27 |
+
if args.adapter == "apairo":
|
| 28 |
+
from .adapters.apairo_source import ApairoSource
|
| 29 |
+
|
| 30 |
+
launch(ApairoSource.from_path(args.path), title=f"Splasher — {args.path}")
|
| 31 |
+
return 0
|
| 32 |
+
|
| 33 |
+
parser.error("précise un --adapter (ex. --adapter apairo) ou utilise 'demo'")
|
| 34 |
+
return 2
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
if __name__ == "__main__":
|
| 38 |
+
sys.exit(main())
|
splasher/core/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Cœur de Splasher : protocole d'entrée, grille, projection, cibles de labels. numpy uniquement."""
|
splasher/core/__pycache__/__init__.cpython-312.pyc
ADDED
|
Binary file (264 Bytes). View file
|
|
|
splasher/core/__pycache__/accumulate.cpython-312.pyc
ADDED
|
Binary file (5.35 kB). View file
|
|
|
splasher/core/__pycache__/array_source.cpython-312.pyc
ADDED
|
Binary file (3.27 kB). View file
|
|
|
splasher/core/__pycache__/colormap.cpython-312.pyc
ADDED
|
Binary file (2.48 kB). View file
|
|
|
splasher/core/__pycache__/grid.cpython-312.pyc
ADDED
|
Binary file (8.35 kB). View file
|
|
|
splasher/core/__pycache__/io.cpython-312.pyc
ADDED
|
Binary file (5.52 kB). View file
|
|
|
splasher/core/__pycache__/labels.cpython-312.pyc
ADDED
|
Binary file (6.52 kB). View file
|
|
|
splasher/core/__pycache__/poses.cpython-312.pyc
ADDED
|
Binary file (3.57 kB). View file
|
|
|
splasher/core/__pycache__/projection.cpython-312.pyc
ADDED
|
Binary file (5.87 kB). View file
|
|
|
splasher/core/__pycache__/source.cpython-312.pyc
ADDED
|
Binary file (4 kB). View file
|
|
|
splasher/core/__pycache__/target.cpython-312.pyc
ADDED
|
Binary file (10.6 kB). View file
|
|
|
splasher/core/accumulate.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Cumul de frames par registration des poses, avec traçabilité pour le décumul.
|
| 2 |
+
|
| 3 |
+
On accumule une fenêtre de frames **dans le repère du frame de référence** (le frame
|
| 4 |
+
courant) : chaque frame `j` est ramené par `inv(pose_ref) @ pose_j`. Chaque point
|
| 5 |
+
accumulé garde :
|
| 6 |
+
- `frame_id` : frame source,
|
| 7 |
+
- `chan_id` : indice du canal nuage source (dans `cloud_keys`),
|
| 8 |
+
- `point_id` : indice du point dans **la concaténation complète** des canaux nuage de
|
| 9 |
+
ce frame (ordre = `cloud_keys`), fixe quelle que soit la visibilité des canaux.
|
| 10 |
+
|
| 11 |
+
Cela permet de **décumuler** des labels peints sur le nuage cumulé vers chaque frame
|
| 12 |
+
d'origine, et de **filtrer par canal** sans jamais désaligner les labels points
|
| 13 |
+
(qui restent dimensionnés sur la concaténation complète).
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
from dataclasses import dataclass, field
|
| 19 |
+
|
| 20 |
+
import numpy as np
|
| 21 |
+
|
| 22 |
+
from .poses import invert, pose_to_matrix, transform_points
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
@dataclass
|
| 26 |
+
class Accumulation:
|
| 27 |
+
points: np.ndarray # (M, 3+) dans le repère de référence
|
| 28 |
+
frame_id: np.ndarray # (M,) frame source
|
| 29 |
+
chan_id: np.ndarray # (M,) indice de canal nuage source
|
| 30 |
+
point_id: np.ndarray # (M,) indice dans la concaténation complète du frame
|
| 31 |
+
counts: dict[int, int] = field(default_factory=dict) # frame -> nb total de points
|
| 32 |
+
|
| 33 |
+
@property
|
| 34 |
+
def xy(self) -> np.ndarray:
|
| 35 |
+
return self.points[:, :2]
|
| 36 |
+
|
| 37 |
+
def visible_mask(self, visible_chan_indices) -> np.ndarray:
|
| 38 |
+
"""Masque des points dont le canal est dans `visible_chan_indices`."""
|
| 39 |
+
if len(self.chan_id) == 0:
|
| 40 |
+
return np.zeros(0, dtype=bool)
|
| 41 |
+
return np.isin(self.chan_id, np.asarray(list(visible_chan_indices), dtype=np.int64))
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def window_indices(ref_idx: int, radius: int, n_frames: int) -> list[int]:
|
| 45 |
+
"""Fenêtre `[ref-radius, ref+radius]` bornée à `[0, n_frames)`."""
|
| 46 |
+
lo = max(0, ref_idx - radius)
|
| 47 |
+
hi = min(n_frames, ref_idx + radius + 1)
|
| 48 |
+
return list(range(lo, hi))
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def accumulate(source, ref_idx: int, indices: list[int], cloud_keys: list[str],
|
| 52 |
+
pose_key: str | None = None) -> Accumulation:
|
| 53 |
+
"""Accumule `indices` dans le repère de `ref_idx`. `pose_key=None` -> identité."""
|
| 54 |
+
p_ref_inv = None
|
| 55 |
+
if pose_key is not None:
|
| 56 |
+
p_ref_inv = invert(pose_to_matrix(source[ref_idx].channels[pose_key]))
|
| 57 |
+
|
| 58 |
+
pts, fids, cids, pids = [], [], [], []
|
| 59 |
+
counts: dict[int, int] = {}
|
| 60 |
+
for j in indices:
|
| 61 |
+
frame = source[j]
|
| 62 |
+
if pose_key is not None and j != ref_idx:
|
| 63 |
+
T = p_ref_inv @ pose_to_matrix(frame.channels[pose_key])
|
| 64 |
+
else:
|
| 65 |
+
T = None # identité (frame de référence, ou pas de pose)
|
| 66 |
+
|
| 67 |
+
offset = 0
|
| 68 |
+
for ci, key in enumerate(cloud_keys):
|
| 69 |
+
p = frame.channels.get(key)
|
| 70 |
+
if p is None or len(p) == 0:
|
| 71 |
+
continue
|
| 72 |
+
q = np.asarray(p, dtype=np.float64).copy() if T is None else transform_points(p, T)
|
| 73 |
+
m = len(p)
|
| 74 |
+
pts.append(q)
|
| 75 |
+
fids.append(np.full(m, j, dtype=np.int64))
|
| 76 |
+
cids.append(np.full(m, ci, dtype=np.int64))
|
| 77 |
+
pids.append(offset + np.arange(m, dtype=np.int64))
|
| 78 |
+
offset += m
|
| 79 |
+
counts[j] = offset
|
| 80 |
+
|
| 81 |
+
if pts:
|
| 82 |
+
return Accumulation(
|
| 83 |
+
np.concatenate(pts, axis=0),
|
| 84 |
+
np.concatenate(fids),
|
| 85 |
+
np.concatenate(cids),
|
| 86 |
+
np.concatenate(pids),
|
| 87 |
+
counts,
|
| 88 |
+
)
|
| 89 |
+
return Accumulation(
|
| 90 |
+
np.zeros((0, 3)), np.zeros(0, np.int64), np.zeros(0, np.int64), np.zeros(0, np.int64), counts
|
| 91 |
+
)
|
splasher/core/array_source.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""`ArraySource` — une `Source` construite depuis des tableaux numpy en mémoire.
|
| 2 |
+
|
| 3 |
+
C'est l'entrée par défaut, sans aucune dépendance : utile pour les démos, les
|
| 4 |
+
tests, et tout pipeline qui produit déjà des arrays. Aucune I/O cachée.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
from typing import Mapping, Sequence
|
| 10 |
+
|
| 11 |
+
import numpy as np
|
| 12 |
+
|
| 13 |
+
from .source import ChannelSpec, Frame, Source
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class ArraySource(Source):
|
| 17 |
+
"""Source synchrone en mémoire.
|
| 18 |
+
|
| 19 |
+
Parameters
|
| 20 |
+
----------
|
| 21 |
+
specs:
|
| 22 |
+
Description des canaux (`ChannelSpec`). L'ordre est conservé.
|
| 23 |
+
frames:
|
| 24 |
+
Une séquence de dicts `{nom_canal: np.ndarray}`, un par pas de temps.
|
| 25 |
+
timestamps:
|
| 26 |
+
Optionnel ; horodatages par frame. `None` (défaut) = synchrone.
|
| 27 |
+
"""
|
| 28 |
+
|
| 29 |
+
def __init__(
|
| 30 |
+
self,
|
| 31 |
+
specs: Sequence[ChannelSpec],
|
| 32 |
+
frames: Sequence[Mapping[str, np.ndarray]],
|
| 33 |
+
timestamps: Sequence[float] | None = None,
|
| 34 |
+
) -> None:
|
| 35 |
+
self._specs = list(specs)
|
| 36 |
+
self._frames = [dict(f) for f in frames]
|
| 37 |
+
if timestamps is not None and len(timestamps) != len(self._frames):
|
| 38 |
+
raise ValueError("timestamps doit avoir la même longueur que frames")
|
| 39 |
+
self._timestamps = None if timestamps is None else [float(t) for t in timestamps]
|
| 40 |
+
|
| 41 |
+
names = {s.name for s in self._specs}
|
| 42 |
+
for i, fr in enumerate(self._frames):
|
| 43 |
+
missing = names - set(fr.keys())
|
| 44 |
+
if missing:
|
| 45 |
+
raise ValueError(f"frame {i}: canaux manquants {sorted(missing)}")
|
| 46 |
+
|
| 47 |
+
def __len__(self) -> int:
|
| 48 |
+
return len(self._frames)
|
| 49 |
+
|
| 50 |
+
def __getitem__(self, index: int) -> Frame:
|
| 51 |
+
ts = None if self._timestamps is None else self._timestamps[index]
|
| 52 |
+
return Frame(channels=dict(self._frames[index]), timestamp=ts)
|
| 53 |
+
|
| 54 |
+
def channels(self) -> list[ChannelSpec]:
|
| 55 |
+
return list(self._specs)
|
splasher/core/colormap.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Petit colormap viridis numpy-pur (pas de matplotlib) : valeurs -> RGBA float [0,1]."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import numpy as np
|
| 6 |
+
|
| 7 |
+
# Ancres viridis approximées (0 -> 1).
|
| 8 |
+
_VIRIDIS = (
|
| 9 |
+
np.array(
|
| 10 |
+
[
|
| 11 |
+
[68, 1, 84],
|
| 12 |
+
[59, 82, 139],
|
| 13 |
+
[33, 145, 140],
|
| 14 |
+
[94, 201, 98],
|
| 15 |
+
[253, 231, 37],
|
| 16 |
+
],
|
| 17 |
+
dtype=np.float32,
|
| 18 |
+
)
|
| 19 |
+
/ 255.0
|
| 20 |
+
)
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def colormap(values, *, vmin: float | None = None, vmax: float | None = None,
|
| 24 |
+
alpha: float = 1.0, lut: np.ndarray = _VIRIDIS) -> np.ndarray:
|
| 25 |
+
"""Mappe `values` (1D) vers un tableau RGBA `(N, 4)` float32 dans [0, 1].
|
| 26 |
+
|
| 27 |
+
`vmin`/`vmax` par défaut = min/max finis des valeurs.
|
| 28 |
+
"""
|
| 29 |
+
v = np.asarray(values, dtype=np.float32).ravel()
|
| 30 |
+
finite = np.isfinite(v)
|
| 31 |
+
if vmin is None:
|
| 32 |
+
vmin = float(v[finite].min()) if finite.any() else 0.0
|
| 33 |
+
if vmax is None:
|
| 34 |
+
vmax = float(v[finite].max()) if finite.any() else 1.0
|
| 35 |
+
if vmax <= vmin:
|
| 36 |
+
vmax = vmin + 1.0
|
| 37 |
+
|
| 38 |
+
t = np.clip((v - vmin) / (vmax - vmin), 0.0, 1.0)
|
| 39 |
+
t = np.where(finite, t, 0.0)
|
| 40 |
+
|
| 41 |
+
n = len(lut) - 1
|
| 42 |
+
pos = t * n
|
| 43 |
+
lo = np.floor(pos).astype(np.intp)
|
| 44 |
+
hi = np.minimum(lo + 1, n)
|
| 45 |
+
frac = (pos - lo)[:, None]
|
| 46 |
+
rgb = lut[lo] * (1.0 - frac) + lut[hi] * frac
|
| 47 |
+
|
| 48 |
+
out = np.empty((v.shape[0], 4), dtype=np.float32)
|
| 49 |
+
out[:, :3] = rgb
|
| 50 |
+
out[:, 3] = alpha
|
| 51 |
+
return out
|
splasher/core/grid.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""`Grid` — la grille de carrés vue-de-dessus (BEV), définie en unités monde.
|
| 2 |
+
|
| 3 |
+
C'est la première chose qu'on conçoit : une étendue monde `(xmin..xmax, ymin..ymax)`
|
| 4 |
+
et la taille d'un carré `cell_size` (mètres). On en déduit `cols × rows`. La grille
|
| 5 |
+
fournit le mapping monde <-> cellule, un raster vide, et les segments de lignes pour
|
| 6 |
+
l'affichage.
|
| 7 |
+
|
| 8 |
+
Conventions :
|
| 9 |
+
- `j` (colonne) indexe `x` : `j = floor((x - xmin) / cell_size)`
|
| 10 |
+
- `i` (ligne) indexe `y` : `i = floor((y - ymin) / cell_size)`
|
| 11 |
+
- un raster a la forme `(rows, cols)`, indexé `raster[i, j]`
|
| 12 |
+
- l'origine `(xmin, ymin)` est en bas-gauche, `y` vers le haut.
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
import math
|
| 18 |
+
from dataclasses import dataclass
|
| 19 |
+
|
| 20 |
+
import numpy as np
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
@dataclass(frozen=True)
|
| 24 |
+
class Grid:
|
| 25 |
+
xmin: float
|
| 26 |
+
xmax: float
|
| 27 |
+
ymin: float
|
| 28 |
+
ymax: float
|
| 29 |
+
cell_size: float
|
| 30 |
+
|
| 31 |
+
def __post_init__(self) -> None:
|
| 32 |
+
if self.cell_size <= 0:
|
| 33 |
+
raise ValueError("cell_size doit être > 0")
|
| 34 |
+
if self.xmax <= self.xmin or self.ymax <= self.ymin:
|
| 35 |
+
raise ValueError("étendue invalide (xmax > xmin et ymax > ymin requis)")
|
| 36 |
+
|
| 37 |
+
# --- dimensions -------------------------------------------------------
|
| 38 |
+
@property
|
| 39 |
+
def cols(self) -> int:
|
| 40 |
+
return max(1, math.ceil((self.xmax - self.xmin) / self.cell_size))
|
| 41 |
+
|
| 42 |
+
@property
|
| 43 |
+
def rows(self) -> int:
|
| 44 |
+
return max(1, math.ceil((self.ymax - self.ymin) / self.cell_size))
|
| 45 |
+
|
| 46 |
+
@property
|
| 47 |
+
def shape(self) -> tuple[int, int]:
|
| 48 |
+
return (self.rows, self.cols)
|
| 49 |
+
|
| 50 |
+
@property
|
| 51 |
+
def width(self) -> float:
|
| 52 |
+
"""Largeur réelle couverte par les carrés (cols * cell_size)."""
|
| 53 |
+
return self.cols * self.cell_size
|
| 54 |
+
|
| 55 |
+
@property
|
| 56 |
+
def height(self) -> float:
|
| 57 |
+
return self.rows * self.cell_size
|
| 58 |
+
|
| 59 |
+
@property
|
| 60 |
+
def extent(self) -> tuple[float, float, float, float]:
|
| 61 |
+
return (self.xmin, self.xmax, self.ymin, self.ymax)
|
| 62 |
+
|
| 63 |
+
# --- mapping monde <-> cellule ---------------------------------------
|
| 64 |
+
def world_to_cell(self, xy: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
|
| 65 |
+
"""`xy` (N, 2) -> (`ij` (N, 2) int [ligne, colonne], `valid` (N,) bool)."""
|
| 66 |
+
xy = np.asarray(xy, dtype=np.float64).reshape(-1, 2)
|
| 67 |
+
j = np.floor((xy[:, 0] - self.xmin) / self.cell_size).astype(np.intp)
|
| 68 |
+
i = np.floor((xy[:, 1] - self.ymin) / self.cell_size).astype(np.intp)
|
| 69 |
+
valid = (j >= 0) & (j < self.cols) & (i >= 0) & (i < self.rows)
|
| 70 |
+
return np.stack([i, j], axis=1), valid
|
| 71 |
+
|
| 72 |
+
def cell_to_world(self, i: int, j: int) -> tuple[float, float]:
|
| 73 |
+
"""Centre monde de la cellule `(i, j)`."""
|
| 74 |
+
x = self.xmin + (j + 0.5) * self.cell_size
|
| 75 |
+
y = self.ymin + (i + 0.5) * self.cell_size
|
| 76 |
+
return (x, y)
|
| 77 |
+
|
| 78 |
+
def empty_raster(self, fill: int = 0, dtype=np.int32) -> np.ndarray:
|
| 79 |
+
return np.full((self.rows, self.cols), fill, dtype=dtype)
|
| 80 |
+
|
| 81 |
+
# --- affichage --------------------------------------------------------
|
| 82 |
+
def image_rect(self) -> tuple[float, float, float, float]:
|
| 83 |
+
"""`(x, y, w, h)` pour positionner un `ImageItem (rows, cols)` en coords monde."""
|
| 84 |
+
return (self.xmin, self.ymin, self.width, self.height)
|
| 85 |
+
|
| 86 |
+
def line_segments(self) -> tuple[np.ndarray, np.ndarray]:
|
| 87 |
+
"""Segments des lignes de la grille pour un tracé `connect='pairs'`."""
|
| 88 |
+
x0, y0 = self.xmin, self.ymin
|
| 89 |
+
x1, y1 = self.xmin + self.width, self.ymin + self.height
|
| 90 |
+
vx = self.xmin + self.cell_size * np.arange(self.cols + 1)
|
| 91 |
+
hy = self.ymin + self.cell_size * np.arange(self.rows + 1)
|
| 92 |
+
|
| 93 |
+
xs_v = np.repeat(vx, 2)
|
| 94 |
+
ys_v = np.tile([y0, y1], self.cols + 1)
|
| 95 |
+
xs_h = np.tile([x0, x1], self.rows + 1)
|
| 96 |
+
ys_h = np.repeat(hy, 2)
|
| 97 |
+
|
| 98 |
+
xs = np.concatenate([xs_v, xs_h]).astype(np.float64)
|
| 99 |
+
ys = np.concatenate([ys_v, ys_h]).astype(np.float64)
|
| 100 |
+
return xs, ys
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def grid_from_points(xy: np.ndarray, cell_size: float = 1.0,
|
| 104 |
+
margin: float = 2.0) -> Grid:
|
| 105 |
+
"""Grille par défaut englobant un nuage de points top-down `xy` (N, 2)."""
|
| 106 |
+
xy = np.asarray(xy, dtype=np.float64).reshape(-1, 2)
|
| 107 |
+
if len(xy) == 0:
|
| 108 |
+
return Grid(-10.0, 10.0, -10.0, 10.0, cell_size)
|
| 109 |
+
lo = np.floor(xy.min(axis=0) - margin)
|
| 110 |
+
hi = np.ceil(xy.max(axis=0) + margin)
|
| 111 |
+
return Grid(float(lo[0]), float(hi[0]), float(lo[1]), float(hi[1]), cell_size)
|
splasher/core/io.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Sauvegarde/chargement d'une session de labels — fichiers autonomes.
|
| 2 |
+
|
| 3 |
+
Format sur disque (dossier de sortie) :
|
| 4 |
+
|
| 5 |
+
session.json # grille (étendue, cell_size) + jeu de classes
|
| 6 |
+
grid/frame_00007.npy # raster d'ids (rows, cols), un par sample labélisé
|
| 7 |
+
grid/frame_00007.png # même raster colorisé (aperçu, y vers le haut)
|
| 8 |
+
grid/global.npy # raster global (si labélisé)
|
| 9 |
+
points/frame_00007.npy# labels (N,) int64 par frame (segmentation)
|
| 10 |
+
|
| 11 |
+
Les `.npy`/`.json` n'utilisent que numpy + stdlib. Le `.png` (aperçu) passe par Qt
|
| 12 |
+
si disponible, sinon il est simplement omis.
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
import json
|
| 18 |
+
from pathlib import Path
|
| 19 |
+
|
| 20 |
+
import numpy as np
|
| 21 |
+
|
| 22 |
+
from .grid import Grid
|
| 23 |
+
from .labels import LabelSet
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def _save_png(path: Path, rgba: np.ndarray) -> bool:
|
| 27 |
+
try:
|
| 28 |
+
from PySide6.QtGui import QImage
|
| 29 |
+
except Exception:
|
| 30 |
+
return False
|
| 31 |
+
arr = np.ascontiguousarray(np.flipud(rgba).astype(np.uint8)) # y vers le haut à l'écran
|
| 32 |
+
h, w = arr.shape[:2]
|
| 33 |
+
img = QImage(arr.data, w, h, 4 * w, QImage.Format_RGBA8888)
|
| 34 |
+
return bool(img.copy().save(str(path)))
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def save_session(out_dir, *, grid: Grid, labelset: LabelSet,
|
| 38 |
+
grid_target=None, point_target=None) -> Path:
|
| 39 |
+
out = Path(out_dir)
|
| 40 |
+
out.mkdir(parents=True, exist_ok=True)
|
| 41 |
+
|
| 42 |
+
meta = {
|
| 43 |
+
"grid": {
|
| 44 |
+
"xmin": grid.xmin, "xmax": grid.xmax,
|
| 45 |
+
"ymin": grid.ymin, "ymax": grid.ymax,
|
| 46 |
+
"cell_size": grid.cell_size, "rows": grid.rows, "cols": grid.cols,
|
| 47 |
+
},
|
| 48 |
+
"labels": labelset.to_dict(),
|
| 49 |
+
}
|
| 50 |
+
(out / "session.json").write_text(json.dumps(meta, indent=2, ensure_ascii=False))
|
| 51 |
+
|
| 52 |
+
if grid_target is not None:
|
| 53 |
+
gd = out / "grid"
|
| 54 |
+
gd.mkdir(exist_ok=True)
|
| 55 |
+
for fi, raster in grid_target.rasters().items():
|
| 56 |
+
np.save(gd / f"frame_{fi:05d}.npy", raster)
|
| 57 |
+
_save_png(gd / f"frame_{fi:05d}.png", labelset.colorize(raster, alpha=255))
|
| 58 |
+
glob = grid_target.global_raster()
|
| 59 |
+
if (glob != labelset.ignore_id).any():
|
| 60 |
+
np.save(gd / "global.npy", glob)
|
| 61 |
+
_save_png(gd / "global.png", labelset.colorize(glob, alpha=255))
|
| 62 |
+
|
| 63 |
+
if point_target is not None:
|
| 64 |
+
pd = out / "points"
|
| 65 |
+
pd.mkdir(exist_ok=True)
|
| 66 |
+
for fi, lab in point_target.all_labels().items():
|
| 67 |
+
np.save(pd / f"frame_{fi:05d}.npy", lab)
|
| 68 |
+
|
| 69 |
+
return out
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def _frame_idx(path: Path) -> int:
|
| 73 |
+
return int(path.stem.split("_")[1])
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def load_session(out_dir) -> dict:
|
| 77 |
+
"""Renvoie `{grid, labelset, grid_labels: {i: raster}, point_labels: {i: (N,)}}`."""
|
| 78 |
+
out = Path(out_dir)
|
| 79 |
+
meta = json.loads((out / "session.json").read_text())
|
| 80 |
+
g = meta["grid"]
|
| 81 |
+
grid = Grid(g["xmin"], g["xmax"], g["ymin"], g["ymax"], g["cell_size"])
|
| 82 |
+
labelset = LabelSet.from_dict(meta["labels"])
|
| 83 |
+
|
| 84 |
+
grid_labels: dict[int, np.ndarray] = {}
|
| 85 |
+
gd = out / "grid"
|
| 86 |
+
if gd.is_dir():
|
| 87 |
+
for p in sorted(gd.glob("frame_*.npy")):
|
| 88 |
+
grid_labels[_frame_idx(p)] = np.load(p)
|
| 89 |
+
|
| 90 |
+
point_labels: dict[int, np.ndarray] = {}
|
| 91 |
+
pd = out / "points"
|
| 92 |
+
if pd.is_dir():
|
| 93 |
+
for p in sorted(pd.glob("frame_*.npy")):
|
| 94 |
+
point_labels[_frame_idx(p)] = np.load(p)
|
| 95 |
+
|
| 96 |
+
return {
|
| 97 |
+
"grid": grid,
|
| 98 |
+
"labelset": labelset,
|
| 99 |
+
"grid_labels": grid_labels,
|
| 100 |
+
"point_labels": point_labels,
|
| 101 |
+
}
|
splasher/core/labels.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""`LabelSet` — l'ensemble des classes de labélisation (id, nom, couleur).
|
| 2 |
+
|
| 3 |
+
Générique : aucune classe n'est imposée. Un défaut « traversabilité » est fourni,
|
| 4 |
+
mais on peut charger/sauver n'importe quel jeu de classes en JSON.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import json
|
| 10 |
+
from dataclasses import dataclass
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
|
| 13 |
+
import numpy as np
|
| 14 |
+
|
| 15 |
+
RGB = tuple[int, int, int]
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
@dataclass(frozen=True)
|
| 19 |
+
class LabelClass:
|
| 20 |
+
id: int
|
| 21 |
+
name: str
|
| 22 |
+
color: RGB
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class LabelSet:
|
| 26 |
+
def __init__(self, classes: list[LabelClass], ignore_id: int = 0) -> None:
|
| 27 |
+
self.classes = list(classes)
|
| 28 |
+
self.ignore_id = ignore_id
|
| 29 |
+
self._by_id = {c.id: c for c in self.classes}
|
| 30 |
+
|
| 31 |
+
@property
|
| 32 |
+
def max_id(self) -> int:
|
| 33 |
+
return max((c.id for c in self.classes), default=0)
|
| 34 |
+
|
| 35 |
+
@property
|
| 36 |
+
def paintable(self) -> list[LabelClass]:
|
| 37 |
+
"""Classes assignables (toutes sauf `ignore`)."""
|
| 38 |
+
return [c for c in self.classes if c.id != self.ignore_id]
|
| 39 |
+
|
| 40 |
+
def color_of(self, class_id: int) -> RGB:
|
| 41 |
+
c = self._by_id.get(class_id)
|
| 42 |
+
return c.color if c else (0, 0, 0)
|
| 43 |
+
|
| 44 |
+
def name_of(self, class_id: int) -> str:
|
| 45 |
+
c = self._by_id.get(class_id)
|
| 46 |
+
return c.name if c else str(class_id)
|
| 47 |
+
|
| 48 |
+
def lut(self, alpha: int = 255, max_id: int | None = None) -> np.ndarray:
|
| 49 |
+
"""LUT RGBA `(K, 4)` uint8 indexée par id. `ignore_id` -> alpha 0."""
|
| 50 |
+
top = self.max_id if max_id is None else max(max_id, self.max_id)
|
| 51 |
+
lut = np.zeros((top + 1, 4), dtype=np.uint8)
|
| 52 |
+
for c in self.classes:
|
| 53 |
+
if c.id == self.ignore_id or c.id < 0 or c.id > top:
|
| 54 |
+
continue
|
| 55 |
+
lut[c.id, :3] = c.color
|
| 56 |
+
lut[c.id, 3] = alpha
|
| 57 |
+
return lut
|
| 58 |
+
|
| 59 |
+
def colorize(self, raster: np.ndarray, alpha: int = 255) -> np.ndarray:
|
| 60 |
+
"""Raster d'ids `(rows, cols)` -> image RGBA `(rows, cols, 4)` uint8."""
|
| 61 |
+
max_id = int(raster.max()) if raster.size else 0
|
| 62 |
+
return self.lut(alpha=alpha, max_id=max_id)[raster]
|
| 63 |
+
|
| 64 |
+
# --- (dé)sérialisation ------------------------------------------------
|
| 65 |
+
def to_dict(self) -> dict:
|
| 66 |
+
return {
|
| 67 |
+
"ignore_id": self.ignore_id,
|
| 68 |
+
"classes": [
|
| 69 |
+
{"id": c.id, "name": c.name, "color": list(c.color)} for c in self.classes
|
| 70 |
+
],
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
def save(self, path: str | Path) -> None:
|
| 74 |
+
Path(path).write_text(json.dumps(self.to_dict(), indent=2, ensure_ascii=False))
|
| 75 |
+
|
| 76 |
+
@classmethod
|
| 77 |
+
def from_dict(cls, d: dict) -> "LabelSet":
|
| 78 |
+
classes = [LabelClass(c["id"], c["name"], tuple(c["color"])) for c in d["classes"]]
|
| 79 |
+
return cls(classes, ignore_id=d.get("ignore_id", 0))
|
| 80 |
+
|
| 81 |
+
@classmethod
|
| 82 |
+
def load(cls, path: str | Path) -> "LabelSet":
|
| 83 |
+
return cls.from_dict(json.loads(Path(path).read_text()))
|
| 84 |
+
|
| 85 |
+
@classmethod
|
| 86 |
+
def default(cls) -> "LabelSet":
|
| 87 |
+
"""Jeu par défaut orienté traversabilité (modifiable / remplaçable)."""
|
| 88 |
+
return cls(
|
| 89 |
+
[
|
| 90 |
+
LabelClass(0, "non labélisé", (0, 0, 0)),
|
| 91 |
+
LabelClass(1, "traversable", (60, 200, 70)),
|
| 92 |
+
LabelClass(2, "obstacle", (220, 50, 45)),
|
| 93 |
+
LabelClass(3, "incertain", (235, 170, 30)),
|
| 94 |
+
],
|
| 95 |
+
ignore_id=0,
|
| 96 |
+
)
|
splasher/core/poses.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Poses rigides : conversion en matrice 4x4, inversion, transformation de points.
|
| 2 |
+
|
| 3 |
+
Réimplémenté ici (≈30 lignes) pour ne pas dépendre d'apairo_visu / Open3D.
|
| 4 |
+
Formats acceptés : `(4, 4)`, `(3, 4)`, ou vecteur `(7,)` `[x, y, z, qx, qy, qz, qw]`.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import numpy as np
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def _quat_to_R(q: np.ndarray) -> np.ndarray:
|
| 13 |
+
x, y, z, w = q
|
| 14 |
+
n = x * x + y * y + z * z + w * w
|
| 15 |
+
if n < 1e-12:
|
| 16 |
+
return np.eye(3)
|
| 17 |
+
s = 2.0 / n
|
| 18 |
+
return np.array(
|
| 19 |
+
[
|
| 20 |
+
[1 - s * (y * y + z * z), s * (x * y - z * w), s * (x * z + y * w)],
|
| 21 |
+
[s * (x * y + z * w), 1 - s * (x * x + z * z), s * (y * z - x * w)],
|
| 22 |
+
[s * (x * z - y * w), s * (y * z + x * w), 1 - s * (x * x + y * y)],
|
| 23 |
+
]
|
| 24 |
+
)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def pose_to_matrix(pose: np.ndarray) -> np.ndarray:
|
| 28 |
+
"""Normalise une pose en matrice homogène `(4, 4)` float64."""
|
| 29 |
+
pose = np.asarray(pose, dtype=np.float64)
|
| 30 |
+
if pose.shape == (4, 4):
|
| 31 |
+
return pose.copy()
|
| 32 |
+
if pose.shape == (3, 4):
|
| 33 |
+
T = np.eye(4)
|
| 34 |
+
T[:3, :4] = pose
|
| 35 |
+
return T
|
| 36 |
+
if pose.shape == (7,):
|
| 37 |
+
T = np.eye(4)
|
| 38 |
+
T[:3, :3] = _quat_to_R(pose[3:])
|
| 39 |
+
T[:3, 3] = pose[:3]
|
| 40 |
+
return T
|
| 41 |
+
raise ValueError(f"forme de pose non supportée : {pose.shape}")
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def invert(T: np.ndarray) -> np.ndarray:
|
| 45 |
+
"""Inverse d'une transformation rigide `(4, 4)`."""
|
| 46 |
+
R = T[:3, :3]
|
| 47 |
+
t = T[:3, 3]
|
| 48 |
+
Ti = np.eye(4)
|
| 49 |
+
Ti[:3, :3] = R.T
|
| 50 |
+
Ti[:3, 3] = -R.T @ t
|
| 51 |
+
return Ti
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def transform_points(points: np.ndarray, T: np.ndarray) -> np.ndarray:
|
| 55 |
+
"""Applique `T` aux colonnes xyz de `points` (N, 3+) ; les colonnes en plus sont conservées."""
|
| 56 |
+
if len(points) == 0:
|
| 57 |
+
return points.copy()
|
| 58 |
+
out = np.asarray(points, dtype=np.float64).copy()
|
| 59 |
+
out[:, :3] = points[:, :3] @ T[:3, :3].T + T[:3, 3]
|
| 60 |
+
return out
|
splasher/core/projection.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Projection BEV : nuage de points -> cellules de la grille, et sélection par rectangle.
|
| 2 |
+
|
| 3 |
+
Tout est vectorisé numpy. Sert :
|
| 4 |
+
- la sous-couche de la vue de dessus (densité / hauteur par cellule),
|
| 5 |
+
- la cible Grid (cellules couvertes par un rectangle),
|
| 6 |
+
- la cible Points + le surlignage (masque des points dans un rectangle).
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import numpy as np
|
| 12 |
+
|
| 13 |
+
from .colormap import colormap
|
| 14 |
+
from .grid import Grid
|
| 15 |
+
|
| 16 |
+
Rect = tuple[float, float, float, float] # (x0, y0, x1, y1) en coords monde
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def points_to_cells(xy: np.ndarray, grid: Grid):
|
| 20 |
+
"""Raccourci vers `grid.world_to_cell` : `xy` (N, 2) -> (`ij` (N, 2), `valid`)."""
|
| 21 |
+
return grid.world_to_cell(xy)
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def _reduce_per_cell(points: np.ndarray, grid: Grid, op: str) -> np.ndarray:
|
| 25 |
+
"""Réduit une grandeur par cellule. `op` = 'max_z' ou 'count'. Cellules vides = NaN."""
|
| 26 |
+
out = np.full(grid.rows * grid.cols, -np.inf if op == "max_z" else 0.0, dtype=np.float64)
|
| 27 |
+
if len(points):
|
| 28 |
+
ij, valid = grid.world_to_cell(points[:, :2])
|
| 29 |
+
flat = ij[valid, 0] * grid.cols + ij[valid, 1]
|
| 30 |
+
if op == "max_z":
|
| 31 |
+
np.maximum.at(out, flat, points[valid, 2])
|
| 32 |
+
else:
|
| 33 |
+
np.add.at(out, flat, 1.0)
|
| 34 |
+
out = out.reshape(grid.shape)
|
| 35 |
+
if op == "max_z":
|
| 36 |
+
out[~np.isfinite(out)] = np.nan
|
| 37 |
+
else:
|
| 38 |
+
out[out == 0.0] = np.nan
|
| 39 |
+
return out
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def bev_max_height(points: np.ndarray, grid: Grid) -> np.ndarray:
|
| 43 |
+
"""`(rows, cols)` float : hauteur max (z) par cellule, NaN si vide."""
|
| 44 |
+
return _reduce_per_cell(points, grid, "max_z")
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def bev_count(points: np.ndarray, grid: Grid) -> np.ndarray:
|
| 48 |
+
"""`(rows, cols)` float : nombre de points par cellule, NaN si vide."""
|
| 49 |
+
return _reduce_per_cell(points, grid, "count")
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def bev_image(scalar_field: np.ndarray, *, alpha: int = 210) -> np.ndarray:
|
| 53 |
+
"""Colorise un champ `(rows, cols)` (NaN = transparent) en RGBA uint8 `(rows, cols, 4)`."""
|
| 54 |
+
rows, cols = scalar_field.shape
|
| 55 |
+
rgba = np.zeros((rows, cols, 4), dtype=np.uint8)
|
| 56 |
+
filled = np.isfinite(scalar_field)
|
| 57 |
+
if filled.any():
|
| 58 |
+
colors = colormap(scalar_field[filled])
|
| 59 |
+
rgba[filled, :3] = (colors[:, :3] * 255).astype(np.uint8)
|
| 60 |
+
rgba[filled, 3] = alpha
|
| 61 |
+
return rgba
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def cells_in_rect(rect: Rect, grid: Grid) -> tuple[slice, slice]:
|
| 65 |
+
"""Cellules (lignes, colonnes) couvertes par un rectangle monde -> slices `(i, j)`."""
|
| 66 |
+
x0, y0, x1, y1 = rect
|
| 67 |
+
x0, x1 = sorted((x0, x1))
|
| 68 |
+
y0, y1 = sorted((y0, y1))
|
| 69 |
+
j0 = int(np.clip(np.floor((x0 - grid.xmin) / grid.cell_size), 0, grid.cols))
|
| 70 |
+
j1 = int(np.clip(np.ceil((x1 - grid.xmin) / grid.cell_size), 0, grid.cols))
|
| 71 |
+
i0 = int(np.clip(np.floor((y0 - grid.ymin) / grid.cell_size), 0, grid.rows))
|
| 72 |
+
i1 = int(np.clip(np.ceil((y1 - grid.ymin) / grid.cell_size), 0, grid.rows))
|
| 73 |
+
return slice(i0, i1), slice(j0, j1)
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def points_in_rect(xy: np.ndarray, rect: Rect) -> np.ndarray:
|
| 77 |
+
"""Masque booléen `(N,)` des points dont `(x, y)` tombe dans le rectangle monde."""
|
| 78 |
+
if xy is None or len(xy) == 0:
|
| 79 |
+
return np.zeros(0, dtype=bool)
|
| 80 |
+
x0, y0, x1, y1 = rect
|
| 81 |
+
x0, x1 = sorted((x0, x1))
|
| 82 |
+
y0, y1 = sorted((y0, y1))
|
| 83 |
+
xy = np.asarray(xy)
|
| 84 |
+
return (
|
| 85 |
+
(xy[:, 0] >= x0) & (xy[:, 0] <= x1) & (xy[:, 1] >= y0) & (xy[:, 1] <= y1)
|
| 86 |
+
)
|
splasher/core/source.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Protocole d'entrée de Splasher — le point central de la généricité.
|
| 2 |
+
|
| 3 |
+
Une `Source` est un *dataset synchrone* : à chaque index temporel, elle rend un
|
| 4 |
+
`Frame` = un pack de canaux nommés (tableaux numpy), chacun typé par un
|
| 5 |
+
`ChannelKind`. C'est tout ce que l'outil exige. apairo, des fichiers, des arrays
|
| 6 |
+
en mémoire… ne sont que des manières de produire une `Source`.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
from dataclasses import dataclass
|
| 12 |
+
from enum import Enum
|
| 13 |
+
from typing import Iterable, Protocol, runtime_checkable
|
| 14 |
+
|
| 15 |
+
import numpy as np
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class ChannelKind(Enum):
|
| 19 |
+
"""Nature d'un canal — pilote la vue qui l'affiche et la manière de le labéliser."""
|
| 20 |
+
|
| 21 |
+
POINTCLOUD = "pointcloud" # (N, 3) ou (N, 3+C) : [x, y, z, ...]
|
| 22 |
+
IMAGE = "image" # (H, W) ou (H, W, C) uint8
|
| 23 |
+
POSE = "pose" # (4, 4) ou (7,) [x,y,z, qx,qy,qz,qw] : placement monde
|
| 24 |
+
SCALAR = "scalar" # autre tableau (réservé / extensible)
|
| 25 |
+
|
| 26 |
+
def __repr__(self) -> str: # affichage compact
|
| 27 |
+
return f"ChannelKind.{self.name}"
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
@dataclass(frozen=True)
|
| 31 |
+
class ChannelSpec:
|
| 32 |
+
"""Métadonnées d'un canal. `shape` peut contenir des `None` pour les dims variables."""
|
| 33 |
+
|
| 34 |
+
name: str
|
| 35 |
+
kind: ChannelKind
|
| 36 |
+
dtype: np.dtype | None = None
|
| 37 |
+
shape: tuple[int | None, ...] | None = None
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
@dataclass
|
| 41 |
+
class Frame:
|
| 42 |
+
"""Un pas de temps synchronisé : tous les canaux à un même instant."""
|
| 43 |
+
|
| 44 |
+
channels: dict[str, np.ndarray]
|
| 45 |
+
timestamp: float | None = None # None = synchrone (convention apairo)
|
| 46 |
+
|
| 47 |
+
def __getitem__(self, key: str) -> np.ndarray:
|
| 48 |
+
return self.channels[key]
|
| 49 |
+
|
| 50 |
+
def __contains__(self, key: str) -> bool:
|
| 51 |
+
return key in self.channels
|
| 52 |
+
|
| 53 |
+
def keys(self):
|
| 54 |
+
return self.channels.keys()
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
@runtime_checkable
|
| 58 |
+
class Source(Protocol):
|
| 59 |
+
"""Dataset synchrone : longueur, accès indexé à un `Frame`, et description des canaux."""
|
| 60 |
+
|
| 61 |
+
def __len__(self) -> int: ...
|
| 62 |
+
|
| 63 |
+
def __getitem__(self, index: int) -> Frame: ...
|
| 64 |
+
|
| 65 |
+
def channels(self) -> list[ChannelSpec]: ...
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def channels_of_kind(source_or_specs, kind: ChannelKind) -> list[str]:
|
| 69 |
+
"""Noms des canaux d'un `ChannelKind` donné, depuis une `Source` ou une liste de specs."""
|
| 70 |
+
specs: Iterable[ChannelSpec]
|
| 71 |
+
if hasattr(source_or_specs, "channels"):
|
| 72 |
+
specs = source_or_specs.channels()
|
| 73 |
+
else:
|
| 74 |
+
specs = source_or_specs
|
| 75 |
+
return [s.name for s in specs if s.kind == kind]
|
splasher/core/target.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Cibles de labélisation : ce que produit la sélection d'un rectangle.
|
| 2 |
+
|
| 3 |
+
- `GridTarget` : raster d'ids par sample — sortie « grille telle quelle ».
|
| 4 |
+
- `PointTarget` : labels `(N,)` par frame (segmentation du nuage).
|
| 5 |
+
|
| 6 |
+
**Historique par frame** : chaque frame possède sa propre pile d'annulation. `undo`
|
| 7 |
+
prend le frame courant et n'annule que sa dernière action. Un coup de pinceau cumulé
|
| 8 |
+
(décumulé sur plusieurs frames) est enregistré, de façon atomique, sous le frame de
|
| 9 |
+
référence où il a été peint.
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
from typing import Protocol
|
| 15 |
+
|
| 16 |
+
import numpy as np
|
| 17 |
+
|
| 18 |
+
from .grid import Grid
|
| 19 |
+
from .projection import Rect, cells_in_rect, points_in_rect
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class LabelTarget(Protocol):
|
| 23 |
+
name: str
|
| 24 |
+
|
| 25 |
+
def undo(self, frame_idx: int): ...
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
class GridTarget:
|
| 29 |
+
"""Rasters d'ids par sample (+ un raster global). Cible « grille »."""
|
| 30 |
+
|
| 31 |
+
name = "grid"
|
| 32 |
+
|
| 33 |
+
def __init__(self, grid: Grid, ignore_id: int = 0) -> None:
|
| 34 |
+
self.grid = grid
|
| 35 |
+
self.ignore_id = ignore_id
|
| 36 |
+
self._rasters: dict[int, np.ndarray] = {}
|
| 37 |
+
self._global = grid.empty_raster(ignore_id)
|
| 38 |
+
self._undo: dict[int, list] = {}
|
| 39 |
+
|
| 40 |
+
def raster(self, frame_idx: int, scope: str = "sample") -> np.ndarray:
|
| 41 |
+
if scope == "global":
|
| 42 |
+
return self._global
|
| 43 |
+
r = self._rasters.get(frame_idx)
|
| 44 |
+
if r is None:
|
| 45 |
+
r = self.grid.empty_raster(self.ignore_id)
|
| 46 |
+
self._rasters[frame_idx] = r
|
| 47 |
+
return r
|
| 48 |
+
|
| 49 |
+
def has(self, frame_idx: int, scope: str = "sample") -> bool:
|
| 50 |
+
return scope == "global" or frame_idx in self._rasters
|
| 51 |
+
|
| 52 |
+
def rasters(self) -> dict[int, np.ndarray]:
|
| 53 |
+
return self._rasters
|
| 54 |
+
|
| 55 |
+
def global_raster(self) -> np.ndarray:
|
| 56 |
+
return self._global
|
| 57 |
+
|
| 58 |
+
def load_rasters(self, rasters: dict[int, np.ndarray], global_raster=None) -> None:
|
| 59 |
+
self._rasters = {int(k): np.asarray(v) for k, v in rasters.items()}
|
| 60 |
+
if global_raster is not None:
|
| 61 |
+
self._global = np.asarray(global_raster)
|
| 62 |
+
self._undo.clear()
|
| 63 |
+
|
| 64 |
+
def apply(self, frame_idx: int, rect: Rect, class_id: int, scope: str = "sample") -> bool:
|
| 65 |
+
si, sj = cells_in_rect(rect, self.grid)
|
| 66 |
+
if si.start >= si.stop or sj.start >= sj.stop:
|
| 67 |
+
return False
|
| 68 |
+
return self._set(frame_idx, (si, sj), class_id, scope)
|
| 69 |
+
|
| 70 |
+
def apply_mask(self, frame_idx: int, mask: np.ndarray, class_id: int,
|
| 71 |
+
scope: str = "sample") -> bool:
|
| 72 |
+
"""Peint un masque booléen de cellules `(rows, cols)` (sélection)."""
|
| 73 |
+
if mask is None or not mask.any():
|
| 74 |
+
return False
|
| 75 |
+
return self._set(frame_idx, mask, class_id, scope)
|
| 76 |
+
|
| 77 |
+
def _set(self, frame_idx: int, sel, class_id: int, scope: str) -> bool:
|
| 78 |
+
target = self.raster(frame_idx, scope)
|
| 79 |
+
self._undo.setdefault(frame_idx, []).append((scope, sel, target[sel].copy()))
|
| 80 |
+
target[sel] = class_id
|
| 81 |
+
return True
|
| 82 |
+
|
| 83 |
+
def clear(self, frame_idx: int, scope: str = "sample") -> None:
|
| 84 |
+
target = self.raster(frame_idx, scope)
|
| 85 |
+
self._undo.setdefault(frame_idx, []).append((scope, (slice(None), slice(None)), target.copy()))
|
| 86 |
+
target[:] = self.ignore_id
|
| 87 |
+
|
| 88 |
+
def undo(self, frame_idx: int):
|
| 89 |
+
stack = self._undo.get(frame_idx)
|
| 90 |
+
if not stack:
|
| 91 |
+
return None
|
| 92 |
+
scope, sel, prev = stack.pop()
|
| 93 |
+
self.raster(frame_idx, scope)[sel] = prev
|
| 94 |
+
return scope, frame_idx
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
class PointTarget:
|
| 98 |
+
"""Labels `(N,) int64` par frame. Cible « points » (segmentation du nuage).
|
| 99 |
+
|
| 100 |
+
Les labels sont dimensionnés sur la **concaténation complète** des canaux nuage du
|
| 101 |
+
frame (ordre fixe), donc indépendants de la visibilité des canaux.
|
| 102 |
+
"""
|
| 103 |
+
|
| 104 |
+
name = "points"
|
| 105 |
+
|
| 106 |
+
def __init__(self, ignore_id: int = 0) -> None:
|
| 107 |
+
self.ignore_id = ignore_id
|
| 108 |
+
self._labels: dict[int, np.ndarray] = {}
|
| 109 |
+
self._undo: dict[int, list] = {}
|
| 110 |
+
|
| 111 |
+
def has(self, frame_idx: int) -> bool:
|
| 112 |
+
return frame_idx in self._labels
|
| 113 |
+
|
| 114 |
+
def labels(self, frame_idx: int, n: int | None = None) -> np.ndarray | None:
|
| 115 |
+
lab = self._labels.get(frame_idx)
|
| 116 |
+
if (lab is None or (n is not None and len(lab) != n)) and n is not None:
|
| 117 |
+
lab = np.full(n, self.ignore_id, dtype=np.int64)
|
| 118 |
+
self._labels[frame_idx] = lab
|
| 119 |
+
return lab
|
| 120 |
+
|
| 121 |
+
def all_labels(self) -> dict[int, np.ndarray]:
|
| 122 |
+
return self._labels
|
| 123 |
+
|
| 124 |
+
def load_labels(self, labels: dict[int, np.ndarray]) -> None:
|
| 125 |
+
self._labels = {int(k): np.asarray(v, dtype=np.int64) for k, v in labels.items()}
|
| 126 |
+
self._undo.clear()
|
| 127 |
+
|
| 128 |
+
def apply(self, frame_idx: int, rect: Rect, class_id: int, xy: np.ndarray) -> bool:
|
| 129 |
+
mask = points_in_rect(xy, rect)
|
| 130 |
+
if not mask.any():
|
| 131 |
+
return False
|
| 132 |
+
lab = self.labels(frame_idx, len(xy))
|
| 133 |
+
self._undo.setdefault(frame_idx, []).append([(frame_idx, mask, lab[mask].copy())])
|
| 134 |
+
lab[mask] = class_id
|
| 135 |
+
return True
|
| 136 |
+
|
| 137 |
+
def apply_scatter(self, ref_frame: int, frame_to_sel: dict[int, tuple[np.ndarray, int]],
|
| 138 |
+
class_id: int) -> bool:
|
| 139 |
+
"""Assigne `class_id` à des points répartis sur plusieurs frames (décumul).
|
| 140 |
+
|
| 141 |
+
`frame_to_sel` : `{frame_idx: (indices, n_points_du_frame)}`. L'opération est
|
| 142 |
+
enregistrée de façon atomique sous `ref_frame` (le frame courant).
|
| 143 |
+
"""
|
| 144 |
+
changes: list[tuple] = []
|
| 145 |
+
for frame_idx, (indices, n) in frame_to_sel.items():
|
| 146 |
+
if len(indices) == 0:
|
| 147 |
+
continue
|
| 148 |
+
lab = self.labels(frame_idx, n)
|
| 149 |
+
changes.append((frame_idx, indices, lab[indices].copy()))
|
| 150 |
+
lab[indices] = class_id
|
| 151 |
+
if not changes:
|
| 152 |
+
return False
|
| 153 |
+
self._undo.setdefault(ref_frame, []).append(changes)
|
| 154 |
+
return True
|
| 155 |
+
|
| 156 |
+
def clear(self, frame_idx: int, xy: np.ndarray | None = None) -> None:
|
| 157 |
+
lab = self._labels.get(frame_idx)
|
| 158 |
+
if lab is None:
|
| 159 |
+
return
|
| 160 |
+
self._undo.setdefault(frame_idx, []).append([(frame_idx, slice(None), lab.copy())])
|
| 161 |
+
lab[:] = self.ignore_id
|
| 162 |
+
|
| 163 |
+
def undo(self, frame_idx: int):
|
| 164 |
+
stack = self._undo.get(frame_idx)
|
| 165 |
+
if not stack:
|
| 166 |
+
return None
|
| 167 |
+
for f, sel, prev in stack.pop():
|
| 168 |
+
self._labels[f][sel] = prev
|
| 169 |
+
return self.name, frame_idx
|
splasher/demo.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Source synthétique multi-canaux pour démos/tests — aucune donnée externe.
|
| 2 |
+
|
| 3 |
+
Scène « conduite » : l'ego avance en +x. Canaux fournis :
|
| 4 |
+
- `lidar` : sol bruité + obstacles (nuage dense),
|
| 5 |
+
- `lidar_haut` : points en hauteur au-dessus des obstacles (nuage épars, distinct),
|
| 6 |
+
- `camera_avant` / `camera_arriere` : deux images factices,
|
| 7 |
+
- `pose` : matrice 4x4 par frame (pour le cumul).
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import numpy as np
|
| 13 |
+
|
| 14 |
+
from .core.array_source import ArraySource
|
| 15 |
+
from .core.source import ChannelKind, ChannelSpec
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def _make_image(h: int, w: int, t: int, n_frames: int, *, rear: bool = False) -> np.ndarray:
|
| 19 |
+
img = np.empty((h, w, 3), dtype=np.uint8)
|
| 20 |
+
horizon = h // 2
|
| 21 |
+
img[:horizon] = (140, 110, 90) if rear else (90, 120, 200) # ciel (teinte différente derrière)
|
| 22 |
+
img[horizon:] = (70, 110, 70)
|
| 23 |
+
for r in range(horizon, h):
|
| 24 |
+
frac = (r - horizon) / max(1, h - horizon)
|
| 25 |
+
half = int((0.05 + 0.45 * frac) * w)
|
| 26 |
+
c = w // 2
|
| 27 |
+
img[r, max(0, c - half):min(w, c + half)] = (60, 60, 64)
|
| 28 |
+
# « obstacle » mobile (sens inverse derrière)
|
| 29 |
+
prog = t / max(1, n_frames - 1)
|
| 30 |
+
bx = int((1 - prog if rear else prog) * (w - 50))
|
| 31 |
+
img[horizon - 30:horizon + 10, bx:bx + 40] = (80, 150, 210) if rear else (210, 80, 60)
|
| 32 |
+
return img
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def make_demo_source(n_frames: int = 40, seed: int = 0) -> ArraySource:
|
| 36 |
+
rng = np.random.default_rng(seed)
|
| 37 |
+
h, w = 200, 360
|
| 38 |
+
speed = 1.2
|
| 39 |
+
|
| 40 |
+
n_obs = 9
|
| 41 |
+
obs_x = rng.uniform(8.0, 70.0, n_obs)
|
| 42 |
+
obs_y = rng.uniform(-12.0, 12.0, n_obs)
|
| 43 |
+
obs_r = rng.uniform(0.6, 1.8, n_obs)
|
| 44 |
+
obs_h = rng.uniform(1.0, 3.0, n_obs)
|
| 45 |
+
|
| 46 |
+
specs = [
|
| 47 |
+
ChannelSpec("lidar", ChannelKind.POINTCLOUD, np.dtype("float32"), (None, 4)),
|
| 48 |
+
ChannelSpec("lidar_haut", ChannelKind.POINTCLOUD, np.dtype("float32"), (None, 4)),
|
| 49 |
+
ChannelSpec("camera_avant", ChannelKind.IMAGE, np.dtype("uint8"), (h, w, 3)),
|
| 50 |
+
ChannelSpec("camera_arriere", ChannelKind.IMAGE, np.dtype("uint8"), (h, w, 3)),
|
| 51 |
+
ChannelSpec("pose", ChannelKind.POSE, np.dtype("float32"), (4, 4)),
|
| 52 |
+
]
|
| 53 |
+
|
| 54 |
+
frames: list[dict[str, np.ndarray]] = []
|
| 55 |
+
for t in range(n_frames):
|
| 56 |
+
ex = speed * t
|
| 57 |
+
|
| 58 |
+
ng = 14000
|
| 59 |
+
gx = rng.uniform(1.0, 40.0, ng)
|
| 60 |
+
gy = rng.uniform(-20.0, 20.0, ng)
|
| 61 |
+
gz = rng.normal(0.0, 0.03, ng)
|
| 62 |
+
ground = [np.stack([gx, gy, gz, rng.uniform(0.1, 0.3, ng)], axis=1)]
|
| 63 |
+
high = []
|
| 64 |
+
|
| 65 |
+
for k in range(n_obs):
|
| 66 |
+
xr = obs_x[k] - ex
|
| 67 |
+
if 0.0 < xr < 40.0:
|
| 68 |
+
m = 500
|
| 69 |
+
px = xr + rng.normal(0.0, obs_r[k], m)
|
| 70 |
+
py = obs_y[k] + rng.normal(0.0, obs_r[k], m)
|
| 71 |
+
pz = rng.uniform(0.0, obs_h[k], m)
|
| 72 |
+
ground.append(np.stack([px, py, pz, rng.uniform(0.6, 1.0, m)], axis=1))
|
| 73 |
+
# nuage "haut" : points épars au-dessus de l'obstacle
|
| 74 |
+
mh = 80
|
| 75 |
+
hx = xr + rng.normal(0.0, obs_r[k] * 0.5, mh)
|
| 76 |
+
hy = obs_y[k] + rng.normal(0.0, obs_r[k] * 0.5, mh)
|
| 77 |
+
hz = obs_h[k] + rng.uniform(1.0, 3.0, mh)
|
| 78 |
+
high.append(np.stack([hx, hy, hz, rng.uniform(0.2, 0.5, mh)], axis=1))
|
| 79 |
+
|
| 80 |
+
lidar = np.concatenate(ground, axis=0).astype(np.float32)
|
| 81 |
+
lidar_haut = (np.concatenate(high, axis=0) if high
|
| 82 |
+
else np.zeros((0, 4))).astype(np.float32)
|
| 83 |
+
|
| 84 |
+
pose = np.eye(4, dtype=np.float32)
|
| 85 |
+
pose[0, 3] = ex
|
| 86 |
+
|
| 87 |
+
frames.append({
|
| 88 |
+
"lidar": lidar,
|
| 89 |
+
"lidar_haut": lidar_haut,
|
| 90 |
+
"camera_avant": _make_image(h, w, t, n_frames),
|
| 91 |
+
"camera_arriere": _make_image(h, w, t, n_frames, rear=True),
|
| 92 |
+
"pose": pose,
|
| 93 |
+
})
|
| 94 |
+
|
| 95 |
+
return ArraySource(specs, frames)
|
splasher/ui/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""UI Splasher (PySide6 + pyqtgraph). Importée paresseusement par `splasher.launch`."""
|
splasher/ui/__pycache__/__init__.cpython-312.pyc
ADDED
|
Binary file (248 Bytes). View file
|
|
|
splasher/ui/__pycache__/channel_manager.cpython-312.pyc
ADDED
|
Binary file (3.83 kB). View file
|
|
|
splasher/ui/__pycache__/cloud_view.cpython-312.pyc
ADDED
|
Binary file (3.34 kB). View file
|
|
|
splasher/ui/__pycache__/grid_designer.cpython-312.pyc
ADDED
|
Binary file (7.23 kB). View file
|
|
|
splasher/ui/__pycache__/grid_view.cpython-312.pyc
ADDED
|
Binary file (9.59 kB). View file
|
|
|
splasher/ui/__pycache__/image_view.cpython-312.pyc
ADDED
|
Binary file (2.47 kB). View file
|
|
|
splasher/ui/__pycache__/main_window.cpython-312.pyc
ADDED
|
Binary file (33.7 kB). View file
|
|
|
splasher/ui/__pycache__/palette.cpython-312.pyc
ADDED
|
Binary file (3.45 kB). View file
|
|
|
splasher/ui/__pycache__/timeline.cpython-312.pyc
ADDED
|
Binary file (4.31 kB). View file
|
|
|
splasher/ui/app.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Point d'entrée UI : `launch(source)`."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import sys
|
| 6 |
+
|
| 7 |
+
import pyqtgraph as pg
|
| 8 |
+
from PySide6 import QtWidgets
|
| 9 |
+
|
| 10 |
+
from ..core.source import Source
|
| 11 |
+
from .main_window import MainWindow
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def launch(source: Source, *, labels=None, title: str = "Splasher", block: bool = True):
|
| 15 |
+
"""Ouvre la fenêtre Splasher sur `source`.
|
| 16 |
+
|
| 17 |
+
`labels` : un `LabelSet` optionnel (défaut = `LabelSet.default()`).
|
| 18 |
+
Réutilise une `QApplication` existante si présente. Si `block` et qu'aucune
|
| 19 |
+
boucle n'est active, lance `app.exec()`. Renvoie la fenêtre.
|
| 20 |
+
"""
|
| 21 |
+
pg.setConfigOptions(imageAxisOrder="row-major", antialias=False)
|
| 22 |
+
|
| 23 |
+
app = QtWidgets.QApplication.instance()
|
| 24 |
+
owns_app = app is None
|
| 25 |
+
if owns_app:
|
| 26 |
+
app = QtWidgets.QApplication(sys.argv[:1])
|
| 27 |
+
|
| 28 |
+
window = MainWindow(source, title=title, labelset=labels)
|
| 29 |
+
window.show()
|
| 30 |
+
|
| 31 |
+
if block and owns_app:
|
| 32 |
+
app.exec()
|
| 33 |
+
return window
|
splasher/ui/channel_manager.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Gestionnaire de canaux : afficher/masquer les canaux disponibles de la `Source`.
|
| 2 |
+
|
| 3 |
+
Liste tous les canaux (nuages, caméras, poses…). Les nuages et les images sont
|
| 4 |
+
cochables (afficher/masquer la vue correspondante). Les poses/scalaires sont listés
|
| 5 |
+
pour information (non cochables).
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
from PySide6 import QtCore, QtWidgets
|
| 11 |
+
|
| 12 |
+
from ..core.source import ChannelKind, ChannelSpec
|
| 13 |
+
|
| 14 |
+
_KIND_LABEL = {
|
| 15 |
+
ChannelKind.POINTCLOUD: "nuage",
|
| 16 |
+
ChannelKind.IMAGE: "caméra",
|
| 17 |
+
ChannelKind.POSE: "pose",
|
| 18 |
+
ChannelKind.SCALAR: "scalaire",
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class ChannelManager(QtWidgets.QWidget):
|
| 23 |
+
visibilityChanged = QtCore.Signal()
|
| 24 |
+
|
| 25 |
+
def __init__(self, specs: list[ChannelSpec]) -> None:
|
| 26 |
+
super().__init__()
|
| 27 |
+
self._boxes: dict[str, QtWidgets.QCheckBox] = {}
|
| 28 |
+
self._kind: dict[str, ChannelKind] = {}
|
| 29 |
+
|
| 30 |
+
layout = QtWidgets.QVBoxLayout(self)
|
| 31 |
+
layout.setContentsMargins(6, 4, 6, 4)
|
| 32 |
+
layout.setSpacing(2)
|
| 33 |
+
|
| 34 |
+
for spec in specs:
|
| 35 |
+
self._kind[spec.name] = spec.kind
|
| 36 |
+
toggleable = spec.kind in (ChannelKind.POINTCLOUD, ChannelKind.IMAGE)
|
| 37 |
+
row = QtWidgets.QCheckBox(f"{spec.name} · {_KIND_LABEL.get(spec.kind, '?')}")
|
| 38 |
+
row.setChecked(toggleable)
|
| 39 |
+
row.setEnabled(toggleable)
|
| 40 |
+
if toggleable:
|
| 41 |
+
row.toggled.connect(lambda _checked: self.visibilityChanged.emit())
|
| 42 |
+
self._boxes[spec.name] = row
|
| 43 |
+
layout.addWidget(row)
|
| 44 |
+
|
| 45 |
+
layout.addStretch(1)
|
| 46 |
+
|
| 47 |
+
def _visible_of_kind(self, kind: ChannelKind) -> set[str]:
|
| 48 |
+
return {name for name, box in self._boxes.items()
|
| 49 |
+
if self._kind[name] == kind and box.isChecked()}
|
| 50 |
+
|
| 51 |
+
def visible_clouds(self) -> set[str]:
|
| 52 |
+
return self._visible_of_kind(ChannelKind.POINTCLOUD)
|
| 53 |
+
|
| 54 |
+
def visible_images(self) -> set[str]:
|
| 55 |
+
return self._visible_of_kind(ChannelKind.IMAGE)
|
splasher/ui/cloud_view.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Vue 3D du/des nuage(s) de points : navigation libre (orbit/pan/zoom)."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import numpy as np
|
| 6 |
+
import pyqtgraph.opengl as gl
|
| 7 |
+
|
| 8 |
+
from ..core.colormap import colormap
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class CloudView(gl.GLViewWidget):
|
| 12 |
+
"""Affiche un ou plusieurs nuages nommés. Couleur par hauteur (z) par défaut."""
|
| 13 |
+
|
| 14 |
+
def __init__(self) -> None:
|
| 15 |
+
super().__init__()
|
| 16 |
+
self.setBackgroundColor("#101014")
|
| 17 |
+
self.setCameraPosition(distance=60.0, elevation=30.0, azimuth=-60.0)
|
| 18 |
+
|
| 19 |
+
grid = gl.GLGridItem()
|
| 20 |
+
grid.setSize(x=100, y=100)
|
| 21 |
+
grid.setSpacing(x=5, y=5)
|
| 22 |
+
grid.setColor((255, 255, 255, 40))
|
| 23 |
+
self.addItem(grid)
|
| 24 |
+
self._grid = grid
|
| 25 |
+
|
| 26 |
+
self._scatters: dict[str, gl.GLScatterPlotItem] = {}
|
| 27 |
+
|
| 28 |
+
def set_cloud(self, name: str, points: np.ndarray, colors: np.ndarray | None = None,
|
| 29 |
+
size: float = 2.0) -> None:
|
| 30 |
+
"""Met à jour (ou crée) le nuage `name`. `points` (N, 3+), `colors` (N, 4) RGBA [0,1]."""
|
| 31 |
+
if points is None or len(points) == 0:
|
| 32 |
+
if name in self._scatters:
|
| 33 |
+
self._scatters[name].setData(pos=np.zeros((0, 3), np.float32))
|
| 34 |
+
return
|
| 35 |
+
|
| 36 |
+
xyz = np.ascontiguousarray(points[:, :3], dtype=np.float32)
|
| 37 |
+
if colors is None:
|
| 38 |
+
colors = colormap(xyz[:, 2])
|
| 39 |
+
|
| 40 |
+
item = self._scatters.get(name)
|
| 41 |
+
if item is None:
|
| 42 |
+
item = gl.GLScatterPlotItem(pxMode=True)
|
| 43 |
+
# 'opaque' (et non l'additif par défaut) : les couleurs ne saturent pas
|
| 44 |
+
# vers le blanc quand des dizaines de milliers de points se superposent (cumul).
|
| 45 |
+
item.setGLOptions("opaque")
|
| 46 |
+
self.addItem(item)
|
| 47 |
+
self._scatters[name] = item
|
| 48 |
+
item.setData(pos=xyz, color=colors, size=size)
|
| 49 |
+
|
| 50 |
+
def clear_clouds(self) -> None:
|
| 51 |
+
for item in self._scatters.values():
|
| 52 |
+
item.setData(pos=np.zeros((0, 3), np.float32))
|
splasher/ui/grid_designer.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Panneau de conception de la grille : étendue monde + taille de carré.
|
| 2 |
+
|
| 3 |
+
Deux temps, pour que ce soit clair :
|
| 4 |
+
- éditer les champs met à jour l'**aperçu** des lignes (`previewChanged`) et l'affichage
|
| 5 |
+
`cols × rows`, sans rien effacer ;
|
| 6 |
+
- le bouton **« Nouvelle grille »** crée/applique la grille (`newGridRequested`), ce qui
|
| 7 |
+
**réinitialise** les rasters de grille (ils sont liés à la géométrie).
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
from PySide6 import QtCore, QtWidgets
|
| 13 |
+
|
| 14 |
+
from ..core.grid import Grid
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def _spin(value, lo, hi, step, decimals) -> QtWidgets.QDoubleSpinBox:
|
| 18 |
+
sb = QtWidgets.QDoubleSpinBox()
|
| 19 |
+
sb.setRange(lo, hi)
|
| 20 |
+
sb.setSingleStep(step)
|
| 21 |
+
sb.setDecimals(decimals)
|
| 22 |
+
sb.setValue(value)
|
| 23 |
+
return sb
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class GridDesigner(QtWidgets.QWidget):
|
| 27 |
+
previewChanged = QtCore.Signal(object) # Grid (aperçu, n'efface rien)
|
| 28 |
+
newGridRequested = QtCore.Signal(object) # Grid (création/commit)
|
| 29 |
+
|
| 30 |
+
def __init__(self, grid: Grid) -> None:
|
| 31 |
+
super().__init__()
|
| 32 |
+
self._committed = grid
|
| 33 |
+
|
| 34 |
+
self._xmin = _spin(grid.xmin, -2000, 2000, 1.0, 1)
|
| 35 |
+
self._xmax = _spin(grid.xmax, -2000, 2000, 1.0, 1)
|
| 36 |
+
self._ymin = _spin(grid.ymin, -2000, 2000, 1.0, 1)
|
| 37 |
+
self._ymax = _spin(grid.ymax, -2000, 2000, 1.0, 1)
|
| 38 |
+
self._cell = _spin(grid.cell_size, 0.05, 100.0, 0.5, 2)
|
| 39 |
+
|
| 40 |
+
form = QtWidgets.QFormLayout()
|
| 41 |
+
form.addRow("x min", self._xmin)
|
| 42 |
+
form.addRow("x max", self._xmax)
|
| 43 |
+
form.addRow("y min", self._ymin)
|
| 44 |
+
form.addRow("y max", self._ymax)
|
| 45 |
+
form.addRow("taille carré (m)", self._cell)
|
| 46 |
+
|
| 47 |
+
self._dims = QtWidgets.QLabel()
|
| 48 |
+
self._dims.setStyleSheet("color:#9cf; font-weight:bold;")
|
| 49 |
+
|
| 50 |
+
self._btn = QtWidgets.QPushButton("➕ Nouvelle grille")
|
| 51 |
+
self._btn.setToolTip("Créer/appliquer la grille à cette taille (réinitialise la grille labélisée)")
|
| 52 |
+
self._btn.clicked.connect(self._on_commit)
|
| 53 |
+
|
| 54 |
+
root = QtWidgets.QVBoxLayout(self)
|
| 55 |
+
root.addLayout(form)
|
| 56 |
+
root.addWidget(self._dims)
|
| 57 |
+
root.addWidget(self._btn)
|
| 58 |
+
root.addStretch(1)
|
| 59 |
+
|
| 60 |
+
for sb in (self._xmin, self._xmax, self._ymin, self._ymax, self._cell):
|
| 61 |
+
sb.valueChanged.connect(self._on_edit)
|
| 62 |
+
|
| 63 |
+
self._update_dims(grid)
|
| 64 |
+
|
| 65 |
+
def committed_grid(self) -> Grid:
|
| 66 |
+
return self._committed
|
| 67 |
+
|
| 68 |
+
def set_grid(self, grid: Grid) -> None:
|
| 69 |
+
"""Recale les champs sur `grid` sans rien émettre (ex. après chargement)."""
|
| 70 |
+
self._committed = grid
|
| 71 |
+
for sb, val in ((self._xmin, grid.xmin), (self._xmax, grid.xmax),
|
| 72 |
+
(self._ymin, grid.ymin), (self._ymax, grid.ymax), (self._cell, grid.cell_size)):
|
| 73 |
+
sb.blockSignals(True)
|
| 74 |
+
sb.setValue(val)
|
| 75 |
+
sb.blockSignals(False)
|
| 76 |
+
self._update_dims(grid)
|
| 77 |
+
|
| 78 |
+
def _build(self) -> Grid | None:
|
| 79 |
+
try:
|
| 80 |
+
return Grid(self._xmin.value(), self._xmax.value(),
|
| 81 |
+
self._ymin.value(), self._ymax.value(), self._cell.value())
|
| 82 |
+
except ValueError:
|
| 83 |
+
return None
|
| 84 |
+
|
| 85 |
+
def _update_dims(self, grid: Grid) -> None:
|
| 86 |
+
self._dims.setText(f"{grid.cols} × {grid.rows} carrés ({grid.cols * grid.rows} cellules)")
|
| 87 |
+
|
| 88 |
+
def _on_edit(self) -> None:
|
| 89 |
+
grid = self._build()
|
| 90 |
+
if grid is None:
|
| 91 |
+
self._dims.setText("étendue invalide")
|
| 92 |
+
self._btn.setEnabled(False)
|
| 93 |
+
return
|
| 94 |
+
self._btn.setEnabled(True)
|
| 95 |
+
self._update_dims(grid)
|
| 96 |
+
self.previewChanged.emit(grid)
|
| 97 |
+
|
| 98 |
+
def _on_commit(self) -> None:
|
| 99 |
+
grid = self._build()
|
| 100 |
+
if grid is None:
|
| 101 |
+
return
|
| 102 |
+
self._committed = grid
|
| 103 |
+
self.newGridRequested.emit(grid)
|
splasher/ui/grid_view.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Vue de dessus (BEV) : grille de carrés, sous-couche densité, raster de labels,
|
| 2 |
+
et sélection par rectangle (rubber-band) pour peindre.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
from __future__ import annotations
|
| 6 |
+
|
| 7 |
+
import numpy as np
|
| 8 |
+
import pyqtgraph as pg
|
| 9 |
+
from PySide6 import QtCore, QtWidgets
|
| 10 |
+
|
| 11 |
+
from ..core.grid import Grid
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class _PaintViewBox(pg.ViewBox):
|
| 15 |
+
"""ViewBox avec un mode « peindre » : clic-glisser gauche dessine un rectangle."""
|
| 16 |
+
|
| 17 |
+
rectDrawn = QtCore.Signal(object) # (x0, y0, x1, y1) en coords monde
|
| 18 |
+
|
| 19 |
+
def __init__(self, **kwargs) -> None:
|
| 20 |
+
super().__init__(**kwargs)
|
| 21 |
+
self._paint = True
|
| 22 |
+
self._preview = QtWidgets.QGraphicsRectItem()
|
| 23 |
+
self._preview.setPen(pg.mkPen(255, 213, 74, width=1))
|
| 24 |
+
self._preview.setBrush(pg.mkBrush(255, 213, 74, 50))
|
| 25 |
+
self._preview.setZValue(50)
|
| 26 |
+
self._preview.hide()
|
| 27 |
+
self.addItem(self._preview, ignoreBounds=True)
|
| 28 |
+
|
| 29 |
+
def set_paint_mode(self, on: bool) -> None:
|
| 30 |
+
self._paint = on
|
| 31 |
+
|
| 32 |
+
def mouseDragEvent(self, ev, axis=None):
|
| 33 |
+
if self._paint and ev.button() == QtCore.Qt.LeftButton:
|
| 34 |
+
ev.accept()
|
| 35 |
+
p0 = self.mapSceneToView(ev.buttonDownScenePos())
|
| 36 |
+
p1 = self.mapSceneToView(ev.scenePos())
|
| 37 |
+
x0, y0, x1, y1 = p0.x(), p0.y(), p1.x(), p1.y()
|
| 38 |
+
self._preview.setRect(min(x0, x1), min(y0, y1), abs(x1 - x0), abs(y1 - y0))
|
| 39 |
+
self._preview.show()
|
| 40 |
+
if ev.isFinish():
|
| 41 |
+
self._preview.hide()
|
| 42 |
+
self.rectDrawn.emit((x0, y0, x1, y1))
|
| 43 |
+
else:
|
| 44 |
+
super().mouseDragEvent(ev, axis)
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
class GridView(QtWidgets.QWidget):
|
| 48 |
+
rectDrawn = QtCore.Signal(object)
|
| 49 |
+
|
| 50 |
+
def __init__(self) -> None:
|
| 51 |
+
super().__init__()
|
| 52 |
+
layout = QtWidgets.QVBoxLayout(self)
|
| 53 |
+
layout.setContentsMargins(0, 0, 0, 0)
|
| 54 |
+
|
| 55 |
+
self._glw = pg.GraphicsLayoutWidget()
|
| 56 |
+
layout.addWidget(self._glw)
|
| 57 |
+
self._vb = _PaintViewBox()
|
| 58 |
+
self._vb.setAspectLocked(True) # carrés vraiment carrés, y vers le haut
|
| 59 |
+
self._vb.setBackgroundColor("#0c0c10")
|
| 60 |
+
self._glw.addItem(self._vb)
|
| 61 |
+
self._vb.rectDrawn.connect(self.rectDrawn)
|
| 62 |
+
|
| 63 |
+
self._under = pg.ImageItem() # densité/hauteur BEV (M2)
|
| 64 |
+
self._under.setZValue(-10)
|
| 65 |
+
self._labels = pg.ImageItem() # raster de labels colorisé (M3)
|
| 66 |
+
self._labels.setZValue(-5)
|
| 67 |
+
self._sel = pg.ImageItem() # surbrillance de la sélection (mode select)
|
| 68 |
+
self._sel.setZValue(8)
|
| 69 |
+
self._pts = pg.ScatterPlotItem( # points top-down (référence)
|
| 70 |
+
size=2, pen=None, brush=pg.mkBrush(170, 175, 205, 70), pxMode=True
|
| 71 |
+
)
|
| 72 |
+
self._grid_lines = pg.PlotCurveItem(pen=pg.mkPen(120, 125, 150, 140, width=1))
|
| 73 |
+
self._grid_lines.setZValue(5)
|
| 74 |
+
for item in (self._under, self._labels, self._pts, self._grid_lines, self._sel):
|
| 75 |
+
self._vb.addItem(item)
|
| 76 |
+
|
| 77 |
+
self._grid: Grid | None = None
|
| 78 |
+
|
| 79 |
+
# ------------------------------------------------------------------ API
|
| 80 |
+
def set_paint_mode(self, on: bool) -> None:
|
| 81 |
+
self._vb.set_paint_mode(on)
|
| 82 |
+
|
| 83 |
+
def set_grid(self, grid: Grid, autorange: bool = True) -> None:
|
| 84 |
+
self._grid = grid
|
| 85 |
+
xs, ys = grid.line_segments()
|
| 86 |
+
self._grid_lines.setData(xs, ys, connect="pairs")
|
| 87 |
+
if autorange:
|
| 88 |
+
self._vb.setRange(
|
| 89 |
+
xRange=(grid.xmin, grid.xmin + grid.width),
|
| 90 |
+
yRange=(grid.ymin, grid.ymin + grid.height),
|
| 91 |
+
padding=0.03,
|
| 92 |
+
)
|
| 93 |
+
|
| 94 |
+
def set_topdown_points(self, xy: np.ndarray | None, max_points: int = 12000) -> None:
|
| 95 |
+
if xy is None or len(xy) == 0:
|
| 96 |
+
self._pts.setData(x=[], y=[])
|
| 97 |
+
return
|
| 98 |
+
xy = np.asarray(xy)
|
| 99 |
+
if len(xy) > max_points:
|
| 100 |
+
xy = xy[np.linspace(0, len(xy) - 1, max_points).astype(int)]
|
| 101 |
+
self._pts.setData(x=xy[:, 0], y=xy[:, 1])
|
| 102 |
+
|
| 103 |
+
def set_underlay(self, image: np.ndarray | None, grid: Grid) -> None:
|
| 104 |
+
self._set_raster(self._under, image, grid, levels=(0, 255))
|
| 105 |
+
|
| 106 |
+
def set_labels(self, image: np.ndarray | None, grid: Grid) -> None:
|
| 107 |
+
self._set_raster(self._labels, image, grid, levels=(0, 255))
|
| 108 |
+
|
| 109 |
+
def set_selection(self, mask: np.ndarray | None, grid: Grid) -> None:
|
| 110 |
+
"""Surligne les cellules sélectionnées (`mask` booléen `(rows, cols)`)."""
|
| 111 |
+
if mask is None or not mask.any():
|
| 112 |
+
self._sel.clear()
|
| 113 |
+
return
|
| 114 |
+
rgba = np.zeros((grid.rows, grid.cols, 4), dtype=np.uint8)
|
| 115 |
+
rgba[mask] = (90, 200, 255, 110) # cyan translucide
|
| 116 |
+
self._set_raster(self._sel, rgba, grid, levels=(0, 255))
|
| 117 |
+
|
| 118 |
+
def _set_raster(self, item: pg.ImageItem, image, grid: Grid, levels) -> None:
|
| 119 |
+
if image is None:
|
| 120 |
+
item.clear()
|
| 121 |
+
return
|
| 122 |
+
item.setImage(image, autoLevels=False, levels=levels)
|
| 123 |
+
x, y, w, h = grid.image_rect()
|
| 124 |
+
item.setRect(QtCore.QRectF(x, y, w, h))
|