SmaugC137 commited on
Commit
0483bf4
·
0 Parent(s):

Splasher V1.0

Browse files

Splasher 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
Files changed (50) hide show
  1. README.md +51 -0
  2. examples/demo_arraysource.py +10 -0
  3. pyproject.toml +36 -0
  4. splasher/__init__.py +33 -0
  5. splasher/__pycache__/__init__.cpython-312.pyc +0 -0
  6. splasher/__pycache__/cli.cpython-312.pyc +0 -0
  7. splasher/__pycache__/demo.cpython-312.pyc +0 -0
  8. splasher/adapters/__init__.py +1 -0
  9. splasher/adapters/__pycache__/__init__.cpython-312.pyc +0 -0
  10. splasher/adapters/__pycache__/apairo_source.cpython-312.pyc +0 -0
  11. splasher/adapters/apairo_source.py +72 -0
  12. splasher/cli.py +38 -0
  13. splasher/core/__init__.py +1 -0
  14. splasher/core/__pycache__/__init__.cpython-312.pyc +0 -0
  15. splasher/core/__pycache__/accumulate.cpython-312.pyc +0 -0
  16. splasher/core/__pycache__/array_source.cpython-312.pyc +0 -0
  17. splasher/core/__pycache__/colormap.cpython-312.pyc +0 -0
  18. splasher/core/__pycache__/grid.cpython-312.pyc +0 -0
  19. splasher/core/__pycache__/io.cpython-312.pyc +0 -0
  20. splasher/core/__pycache__/labels.cpython-312.pyc +0 -0
  21. splasher/core/__pycache__/poses.cpython-312.pyc +0 -0
  22. splasher/core/__pycache__/projection.cpython-312.pyc +0 -0
  23. splasher/core/__pycache__/source.cpython-312.pyc +0 -0
  24. splasher/core/__pycache__/target.cpython-312.pyc +0 -0
  25. splasher/core/accumulate.py +91 -0
  26. splasher/core/array_source.py +55 -0
  27. splasher/core/colormap.py +51 -0
  28. splasher/core/grid.py +111 -0
  29. splasher/core/io.py +101 -0
  30. splasher/core/labels.py +96 -0
  31. splasher/core/poses.py +60 -0
  32. splasher/core/projection.py +86 -0
  33. splasher/core/source.py +75 -0
  34. splasher/core/target.py +169 -0
  35. splasher/demo.py +95 -0
  36. splasher/ui/__init__.py +1 -0
  37. splasher/ui/__pycache__/__init__.cpython-312.pyc +0 -0
  38. splasher/ui/__pycache__/channel_manager.cpython-312.pyc +0 -0
  39. splasher/ui/__pycache__/cloud_view.cpython-312.pyc +0 -0
  40. splasher/ui/__pycache__/grid_designer.cpython-312.pyc +0 -0
  41. splasher/ui/__pycache__/grid_view.cpython-312.pyc +0 -0
  42. splasher/ui/__pycache__/image_view.cpython-312.pyc +0 -0
  43. splasher/ui/__pycache__/main_window.cpython-312.pyc +0 -0
  44. splasher/ui/__pycache__/palette.cpython-312.pyc +0 -0
  45. splasher/ui/__pycache__/timeline.cpython-312.pyc +0 -0
  46. splasher/ui/app.py +33 -0
  47. splasher/ui/channel_manager.py +55 -0
  48. splasher/ui/cloud_view.py +52 -0
  49. splasher/ui/grid_designer.py +103 -0
  50. 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))