feat(server): ingest temps réel WS + GUI live + client PC
Serveur FastAPI reçoit le flux JSONL (sim ou ROV réel) sur /ws/ingest, SLAM incrémental, rediffuse carte+poses sur /ws/live, GUI live et export PLY. Déployé Docker sur caddy-net, exposé /moulin-live/. Client PC stream_client.py. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
8
server/Dockerfile
Normal file
8
server/Dockerfile
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
WORKDIR /app
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt "numpy<2.0"
|
||||||
|
COPY . .
|
||||||
|
ENV MOULIN_TOKEN=moulin-2026
|
||||||
|
EXPOSE 8211
|
||||||
|
CMD ["uvicorn","app:app","--host","0.0.0.0","--port","8211"]
|
||||||
66
server/README.md
Normal file
66
server/README.md
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
# moulin-mapper server
|
||||||
|
|
||||||
|
## Lancement local
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd server/
|
||||||
|
python -m venv .venv
|
||||||
|
source .venv/bin/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
uvicorn app:app --host 0.0.0.0 --port 8211
|
||||||
|
```
|
||||||
|
|
||||||
|
GUI accessible sur : http://localhost:8211/
|
||||||
|
|
||||||
|
## Variables d'environnement
|
||||||
|
|
||||||
|
| Variable | Défaut | Description |
|
||||||
|
|----------|--------|-------------|
|
||||||
|
| `MOULIN_TOKEN` | `moulin-2026` | Token authentification ingest WS |
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
| Endpoint | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| `GET /` | GUI live (HTML statique) |
|
||||||
|
| `WS /ws/ingest?token=…` | Reçoit le flux JSONL du client ROV/sim |
|
||||||
|
| `WS /ws/live` | Navigateur s'abonne aux mises à jour temps réel |
|
||||||
|
| `POST /session/reset` | Remet la session à zéro (form: token) |
|
||||||
|
| `GET /healthz` | Santé + compteurs |
|
||||||
|
| `GET /cloud.ply` | Export nuage 3D courant |
|
||||||
|
|
||||||
|
## Déploiement derrière Caddy (préfixe `/moulin-live/`)
|
||||||
|
|
||||||
|
Exemple Caddyfile :
|
||||||
|
```
|
||||||
|
handle /moulin-live/* {
|
||||||
|
uri strip_prefix /moulin-live
|
||||||
|
reverse_proxy localhost:8211
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Important** : la GUI construit les URLs WS depuis `window.location.pathname`,
|
||||||
|
donc le préfixe de déploiement est automatiquement inclus.
|
||||||
|
Ne PAS utiliser d'URLs en dur dans le code client.
|
||||||
|
|
||||||
|
## Lancer le client stream (sur le PC de Flag)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd client/
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
# Simulation rapide (test)
|
||||||
|
python stream_client.py \
|
||||||
|
--file ../../data/sim/run_L.jsonl \
|
||||||
|
--url wss://laboratoire.freeboxos.fr/moulin-live/ws/ingest \
|
||||||
|
--token moulin-2026 \
|
||||||
|
--speed 0
|
||||||
|
|
||||||
|
# Temps réel
|
||||||
|
python stream_client.py \
|
||||||
|
--file ../../data/sim/run_L.jsonl \
|
||||||
|
--url wss://laboratoire.freeboxos.fr/moulin-live/ws/ingest \
|
||||||
|
--token moulin-2026 \
|
||||||
|
--speed 1.0
|
||||||
|
```
|
||||||
228
server/app.py
Normal file
228
server/app.py
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
"""
|
||||||
|
app.py — Serveur FastAPI moulin-mapper.
|
||||||
|
|
||||||
|
Lance avec : uvicorn app:app --host 0.0.0.0 --port 8211
|
||||||
|
Déployé derrière préfixe /moulin-live/ via reverse-proxy Caddy.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from typing import Set
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, HTTPException, Form, Request
|
||||||
|
from fastapi.responses import HTMLResponse, PlainTextResponse, JSONResponse
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
|
from slam_incremental import IncrementalSLAM
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Config
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
TOKEN = os.environ.get("MOULIN_TOKEN", "moulin-2026")
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# État global de session
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
slam = IncrementalSLAM()
|
||||||
|
session_records: list = [] # tous les enregistrements bruts reçus
|
||||||
|
session_start_time: float = time.time()
|
||||||
|
debit_window: list = [] # timestamps récents pour calcul débit
|
||||||
|
|
||||||
|
# Clients /ws/live connectés
|
||||||
|
live_clients: Set[WebSocket] = set()
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# App FastAPI (root_path vide — le préfixe est géré par Caddy côté client)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
app = FastAPI(title="moulin-mapper live")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Broadcast aux clients /ws/live
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def broadcast(payload: dict) -> None:
|
||||||
|
if not live_clients:
|
||||||
|
return
|
||||||
|
msg = json.dumps(payload)
|
||||||
|
dead = set()
|
||||||
|
for ws in list(live_clients):
|
||||||
|
try:
|
||||||
|
await ws.send_text(msg)
|
||||||
|
except Exception:
|
||||||
|
dead.add(ws)
|
||||||
|
live_clients.difference_update(dead)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# WS /ws/ingest — reçoit le flux du client ROV/sim
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@app.websocket("/ws/ingest")
|
||||||
|
async def ws_ingest(websocket: WebSocket, token: str = ""):
|
||||||
|
if token != TOKEN:
|
||||||
|
await websocket.close(code=4001)
|
||||||
|
return
|
||||||
|
|
||||||
|
await websocket.accept()
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
raw = await websocket.receive_text()
|
||||||
|
try:
|
||||||
|
data = json.loads(raw)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Accepte un seul record ou une liste
|
||||||
|
records = data if isinstance(data, list) else [data]
|
||||||
|
|
||||||
|
sweep_completed = False
|
||||||
|
for rec in records:
|
||||||
|
session_records.append(rec)
|
||||||
|
debit_window.append(time.time())
|
||||||
|
# Nettoie la fenêtre (5 secondes)
|
||||||
|
cutoff = time.time() - 5.0
|
||||||
|
while debit_window and debit_window[0] < cutoff:
|
||||||
|
debit_window.pop(0)
|
||||||
|
|
||||||
|
completed = slam.add_record(rec)
|
||||||
|
if completed:
|
||||||
|
sweep_completed = True
|
||||||
|
|
||||||
|
if sweep_completed:
|
||||||
|
delta = slam.get_sweep_delta()
|
||||||
|
delta["debit"] = len(debit_window) / 5.0
|
||||||
|
await broadcast(delta)
|
||||||
|
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[ingest] erreur: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# WS /ws/live — navigateur s'abonne
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@app.websocket("/ws/live")
|
||||||
|
async def ws_live(websocket: WebSocket):
|
||||||
|
await websocket.accept()
|
||||||
|
live_clients.add(websocket)
|
||||||
|
|
||||||
|
# Envoie l'état complet courant dès la connexion
|
||||||
|
try:
|
||||||
|
snapshot = slam.get_state_snapshot()
|
||||||
|
snapshot["debit"] = len(debit_window) / 5.0
|
||||||
|
await websocket.send_text(json.dumps(snapshot))
|
||||||
|
|
||||||
|
# Garde la connexion ouverte jusqu'à déconnexion
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
# ping keepalive (pas de timeout natif FastAPI WS)
|
||||||
|
await asyncio.wait_for(websocket.receive_text(), timeout=30.0)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
await websocket.send_text(json.dumps({"type": "ping"}))
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
live_clients.discard(websocket)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# POST /session/reset
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@app.post("/session/reset")
|
||||||
|
async def session_reset(token: str = Form(...)):
|
||||||
|
if token != TOKEN:
|
||||||
|
raise HTTPException(status_code=403, detail="Token invalide")
|
||||||
|
slam.reset()
|
||||||
|
session_records.clear()
|
||||||
|
debit_window.clear()
|
||||||
|
await broadcast({"type": "reset"})
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# GET /healthz
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@app.get("/healthz")
|
||||||
|
async def healthz():
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"records": slam.records_count,
|
||||||
|
"sweeps": slam.sweeps_done,
|
||||||
|
"map_points": len(slam.imap),
|
||||||
|
"loop_closures": slam.loop_closures,
|
||||||
|
"live_clients": len(live_clients),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# GET /cloud.ply — export nuage 3D (contour extrudé altitude/depth)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@app.get("/cloud.ply")
|
||||||
|
async def cloud_ply():
|
||||||
|
map_arr = slam.imap.get_array()
|
||||||
|
if len(map_arr) == 0:
|
||||||
|
raise HTTPException(status_code=204, detail="Carte vide")
|
||||||
|
|
||||||
|
# Extrude les points 2D sur l'intervalle [depth - altitude, depth]
|
||||||
|
# Utilise les dernières valeurs connues depuis les enregistrements
|
||||||
|
depth = 1.5
|
||||||
|
altitude = 0.5
|
||||||
|
if session_records:
|
||||||
|
last = session_records[-1]
|
||||||
|
depth = last.get("depth", 1.5)
|
||||||
|
altitude = last.get("altitude", 0.5)
|
||||||
|
|
||||||
|
z_top = -depth + altitude # fond (proche du bas)
|
||||||
|
z_bot = -depth # surface de l'eau (z=0)
|
||||||
|
|
||||||
|
pts_3d = []
|
||||||
|
for pt in map_arr:
|
||||||
|
pts_3d.append((pt[0], pt[1], z_top))
|
||||||
|
pts_3d.append((pt[0], pt[1], z_bot))
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
"ply",
|
||||||
|
"format ascii 1.0",
|
||||||
|
f"element vertex {len(pts_3d)}",
|
||||||
|
"property float x",
|
||||||
|
"property float y",
|
||||||
|
"property float z",
|
||||||
|
"end_header",
|
||||||
|
]
|
||||||
|
for p in pts_3d:
|
||||||
|
lines.append(f"{p[0]:.4f} {p[1]:.4f} {p[2]:.4f}")
|
||||||
|
|
||||||
|
content = "\n".join(lines) + "\n"
|
||||||
|
from fastapi.responses import Response
|
||||||
|
return Response(
|
||||||
|
content=content,
|
||||||
|
media_type="application/octet-stream",
|
||||||
|
headers={"Content-Disposition": "attachment; filename=moulin_cloud.ply"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fichiers statiques + page principale
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@app.get("/", response_class=HTMLResponse)
|
||||||
|
async def index():
|
||||||
|
static_path = os.path.join(os.path.dirname(__file__), "static", "index.html")
|
||||||
|
with open(static_path, "r", encoding="utf-8") as f:
|
||||||
|
return f.read()
|
||||||
|
|
||||||
|
app.mount("/static", StaticFiles(directory=os.path.join(os.path.dirname(__file__), "static")), name="static")
|
||||||
46
server/client/README.md
Normal file
46
server/client/README.md
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
# moulin-mapper — client stream
|
||||||
|
|
||||||
|
## Prérequis
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
## Commande (simulation rapide)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python stream_client.py \
|
||||||
|
--file ../data/sim/run_L.jsonl \
|
||||||
|
--url wss://laboratoire.freeboxos.fr/moulin-live/ws/ingest \
|
||||||
|
--token moulin-2026 \
|
||||||
|
--speed 0
|
||||||
|
```
|
||||||
|
|
||||||
|
## Commande temps réel
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python stream_client.py \
|
||||||
|
--file ../data/sim/run_L.jsonl \
|
||||||
|
--url wss://laboratoire.freeboxos.fr/moulin-live/ws/ingest \
|
||||||
|
--token moulin-2026 \
|
||||||
|
--speed 1.0
|
||||||
|
```
|
||||||
|
|
||||||
|
## Bridge ROV (futur)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rov_bridge | python stream_client.py \
|
||||||
|
--stdin \
|
||||||
|
--url wss://laboratoire.freeboxos.fr/moulin-live/ws/ingest \
|
||||||
|
--token moulin-2026
|
||||||
|
```
|
||||||
|
|
||||||
|
## Options
|
||||||
|
|
||||||
|
| Option | Défaut | Description |
|
||||||
|
|--------|--------|-------------|
|
||||||
|
| `--file FILE` | — | Fichier JSONL à envoyer |
|
||||||
|
| `--stdin` | — | Lit depuis stdin (bridge ROV) |
|
||||||
|
| `--url URL` | `ws://127.0.0.1:8211/ws/ingest` | URL WebSocket du serveur |
|
||||||
|
| `--token TOKEN` | `moulin-2026` | Token d'authentification |
|
||||||
|
| `--speed FLOAT` | `1.0` | 0=max, 1=temps réel, 2=2× plus vite |
|
||||||
1
server/client/requirements.txt
Normal file
1
server/client/requirements.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
websockets>=12.0
|
||||||
102
server/client/stream_client.py
Normal file
102
server/client/stream_client.py
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
"""
|
||||||
|
stream_client.py — Envoie un flux JSONL vers le serveur moulin-mapper.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python stream_client.py --file run_L.jsonl --url ws://127.0.0.1:8211/ws/ingest --token moulin-2026 --speed 1.0
|
||||||
|
|
||||||
|
--speed 0 : aussi vite que possible (pas de délai)
|
||||||
|
--speed 1 : temps réel (deltas de t respectés)
|
||||||
|
--stdin : lit depuis stdin au lieu d'un fichier (bridge ROV)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
try:
|
||||||
|
import websockets
|
||||||
|
except ImportError:
|
||||||
|
print("Installe d'abord : pip install websockets")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
async def stream(url: str, token: str, lines, speed: float):
|
||||||
|
# Ajoute le token comme query param
|
||||||
|
sep = "&" if "?" in url else "?"
|
||||||
|
full_url = f"{url}{sep}token={token}"
|
||||||
|
|
||||||
|
t_prev = None
|
||||||
|
sent = 0
|
||||||
|
t_file_prev = None
|
||||||
|
|
||||||
|
print(f"[moulin-stream] connexion → {full_url}")
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
async with websockets.connect(full_url, ping_interval=20, ping_timeout=30) as ws:
|
||||||
|
print("[moulin-stream] connecté")
|
||||||
|
for line in lines:
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
rec = json.loads(line)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
t_rec = rec.get("t", 0.0)
|
||||||
|
|
||||||
|
# Timing
|
||||||
|
if speed > 0 and t_file_prev is not None:
|
||||||
|
dt_file = t_rec - t_file_prev
|
||||||
|
if dt_file > 0:
|
||||||
|
await asyncio.sleep(dt_file / speed)
|
||||||
|
|
||||||
|
t_file_prev = t_rec
|
||||||
|
|
||||||
|
await ws.send(json.dumps(rec))
|
||||||
|
sent += 1
|
||||||
|
|
||||||
|
if sent % 100 == 0:
|
||||||
|
print(f"\r[moulin-stream] {sent} lignes envoyées, t={t_rec:.1f}s", end="", flush=True)
|
||||||
|
|
||||||
|
print(f"\n[moulin-stream] terminé — {sent} enregistrements envoyés")
|
||||||
|
return
|
||||||
|
|
||||||
|
except (websockets.exceptions.ConnectionClosed,
|
||||||
|
ConnectionRefusedError,
|
||||||
|
OSError) as e:
|
||||||
|
print(f"\n[moulin-stream] connexion perdue ({e}), reconnexion dans 3s…")
|
||||||
|
await asyncio.sleep(3.0)
|
||||||
|
# Redémarre depuis le début si on a perdu la connexion
|
||||||
|
# (les données sont déjà lues depuis un itérateur — on ne peut pas rembobiner stdin)
|
||||||
|
print("[moulin-stream] AVERTISSEMENT: relance depuis le début du fichier")
|
||||||
|
break
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="Stream JSONL vers moulin-mapper")
|
||||||
|
src = parser.add_mutually_exclusive_group(required=True)
|
||||||
|
src.add_argument("--file", help="Fichier JSONL à envoyer")
|
||||||
|
src.add_argument("--stdin", action="store_true", help="Lit depuis stdin")
|
||||||
|
parser.add_argument("--url", default="ws://127.0.0.1:8211/ws/ingest",
|
||||||
|
help="URL WebSocket du serveur (ex: wss://lab.freeboxos.fr/moulin-live/ws/ingest)")
|
||||||
|
parser.add_argument("--token", default="moulin-2026", help="Token d'authentification")
|
||||||
|
parser.add_argument("--speed", type=float, default=1.0,
|
||||||
|
help="Multiplicateur de vitesse (0=max, 1=temps réel, 2=2×)")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if args.file:
|
||||||
|
with open(args.file, "r") as f:
|
||||||
|
lines = f.readlines()
|
||||||
|
print(f"[moulin-stream] fichier: {args.file} ({len(lines)} lignes)")
|
||||||
|
else:
|
||||||
|
lines = sys.stdin
|
||||||
|
|
||||||
|
asyncio.run(stream(args.url, args.token, lines, args.speed))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
6
server/requirements.txt
Normal file
6
server/requirements.txt
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
fastapi>=0.111.0
|
||||||
|
uvicorn[standard]>=0.30.0
|
||||||
|
numpy>=1.24.0
|
||||||
|
scipy>=1.11.0
|
||||||
|
websockets>=12.0
|
||||||
|
python-multipart>=0.0.9
|
||||||
86
server/room.py
Normal file
86
server/room.py
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
"""
|
||||||
|
room.py — Géométrie chambre de moulin + raycast 2D. Copie standalone.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from typing import Optional, List, Tuple
|
||||||
|
|
||||||
|
ROOM_POLYGON = np.array([
|
||||||
|
[0.0, 0.0],
|
||||||
|
[8.0, 0.0],
|
||||||
|
[8.0, 5.0],
|
||||||
|
[3.0, 5.0],
|
||||||
|
[3.0, 8.0],
|
||||||
|
[0.0, 8.0],
|
||||||
|
], dtype=float)
|
||||||
|
|
||||||
|
NICHE_WALLS = np.array([
|
||||||
|
[[0.0, 4.0], [3.0, 4.0]],
|
||||||
|
[[0.0, 2.0], [3.0, 2.0]],
|
||||||
|
], dtype=float)
|
||||||
|
|
||||||
|
|
||||||
|
def _polygon_to_segments(poly: np.ndarray) -> List[Tuple[np.ndarray, np.ndarray]]:
|
||||||
|
segs = []
|
||||||
|
n = len(poly)
|
||||||
|
for i in range(n):
|
||||||
|
segs.append((poly[i], poly[(i + 1) % n]))
|
||||||
|
return segs
|
||||||
|
|
||||||
|
|
||||||
|
def get_all_segments() -> List[Tuple[np.ndarray, np.ndarray]]:
|
||||||
|
segs = _polygon_to_segments(ROOM_POLYGON)
|
||||||
|
for wall in NICHE_WALLS:
|
||||||
|
segs.append((wall[0], wall[1]))
|
||||||
|
return segs
|
||||||
|
|
||||||
|
|
||||||
|
_ALL_SEGMENTS = get_all_segments()
|
||||||
|
|
||||||
|
|
||||||
|
def _ray_segment_intersect(
|
||||||
|
origin: np.ndarray,
|
||||||
|
direction: np.ndarray,
|
||||||
|
p0: np.ndarray,
|
||||||
|
p1: np.ndarray,
|
||||||
|
) -> Optional[float]:
|
||||||
|
dx = direction[0]
|
||||||
|
dy = direction[1]
|
||||||
|
ex = p1[0] - p0[0]
|
||||||
|
ey = p1[1] - p0[1]
|
||||||
|
denom = -dx * ey + ex * dy
|
||||||
|
if abs(denom) < 1e-12:
|
||||||
|
return None
|
||||||
|
bx = p0[0] - origin[0]
|
||||||
|
by = p0[1] - origin[1]
|
||||||
|
t = (-bx * ey + ex * by) / denom
|
||||||
|
u = (dx * by - bx * dy) / denom
|
||||||
|
if t >= 0.0 and 0.0 <= u <= 1.0:
|
||||||
|
return t
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def range_to_walls(
|
||||||
|
x: float,
|
||||||
|
y: float,
|
||||||
|
bearing_world_deg: float,
|
||||||
|
max_range: float = 30.0,
|
||||||
|
) -> Optional[float]:
|
||||||
|
bearing_rad = np.deg2rad(bearing_world_deg)
|
||||||
|
direction = np.array([np.sin(bearing_rad), np.cos(bearing_rad)], dtype=float)
|
||||||
|
origin = np.array([x, y], dtype=float)
|
||||||
|
min_t = None
|
||||||
|
for p0, p1 in _ALL_SEGMENTS:
|
||||||
|
t = _ray_segment_intersect(origin, direction, p0, p1)
|
||||||
|
if t is not None and t > 1e-6:
|
||||||
|
if min_t is None or t < min_t:
|
||||||
|
min_t = t
|
||||||
|
if min_t is not None and min_t <= max_range:
|
||||||
|
return min_t
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_room_bounds() -> Tuple[float, float, float, float]:
|
||||||
|
pts = ROOM_POLYGON
|
||||||
|
return float(pts[:, 0].min()), float(pts[:, 0].max()), \
|
||||||
|
float(pts[:, 1].min()), float(pts[:, 1].max())
|
||||||
400
server/slam_incremental.py
Normal file
400
server/slam_incremental.py
Normal file
@@ -0,0 +1,400 @@
|
|||||||
|
"""
|
||||||
|
slam_incremental.py — SLAM incrémental alimenté enregistrement par enregistrement.
|
||||||
|
|
||||||
|
Adapté depuis pipeline/slam.py pour une utilisation serveur (pas de batch).
|
||||||
|
Appel: une seule méthode add_record(rec) par message reçu.
|
||||||
|
Quand un sweep est complété → ICP + fermeture de boucle auto.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from scipy.spatial import cKDTree
|
||||||
|
from typing import List, Optional, Tuple, Dict
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Dead-reckoning
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class DeadReckoning:
|
||||||
|
def __init__(self, alpha_magneto: float = 0.3, tau: float = 2.0):
|
||||||
|
self.x = 0.0
|
||||||
|
self.y = 0.0
|
||||||
|
self.h_gyro = 0.0
|
||||||
|
self.heading_deg = 0.0
|
||||||
|
self.vf = 0.0
|
||||||
|
self.vl = 0.0
|
||||||
|
self.t_prev = None
|
||||||
|
self.alpha = alpha_magneto
|
||||||
|
self.tau = tau
|
||||||
|
|
||||||
|
def reset(self):
|
||||||
|
self.x = 0.0
|
||||||
|
self.y = 0.0
|
||||||
|
self.h_gyro = 0.0
|
||||||
|
self.heading_deg = 0.0
|
||||||
|
self.vf = 0.0
|
||||||
|
self.vl = 0.0
|
||||||
|
self.t_prev = None
|
||||||
|
|
||||||
|
def update(self, t: float, heading_deg: float,
|
||||||
|
ax: float, ay: float, gz: float,
|
||||||
|
vf: float = None, vl: float = 0.0) -> Tuple[float, float, float]:
|
||||||
|
if self.t_prev is None:
|
||||||
|
self.t_prev = t
|
||||||
|
self.h_gyro = heading_deg
|
||||||
|
self.heading_deg = heading_deg
|
||||||
|
return self.x, self.y, self.heading_deg
|
||||||
|
|
||||||
|
dt = t - self.t_prev
|
||||||
|
if dt <= 0:
|
||||||
|
return self.x, self.y, self.heading_deg
|
||||||
|
self.t_prev = t
|
||||||
|
|
||||||
|
self.h_gyro = (self.h_gyro + np.rad2deg(gz) * dt) % 360
|
||||||
|
dh = ((heading_deg - self.h_gyro + 180) % 360) - 180
|
||||||
|
self.h_gyro = (self.h_gyro + self.alpha * dh) % 360
|
||||||
|
self.heading_deg = self.h_gyro
|
||||||
|
|
||||||
|
if vf is not None:
|
||||||
|
self.vf = vf
|
||||||
|
self.vl = vl
|
||||||
|
else:
|
||||||
|
decay = np.exp(-dt / self.tau)
|
||||||
|
self.vf = self.vf * decay + ax * dt
|
||||||
|
self.vl = 0.0
|
||||||
|
|
||||||
|
h_rad = np.deg2rad(self.heading_deg)
|
||||||
|
vx = self.vf * np.sin(h_rad) - self.vl * np.cos(h_rad)
|
||||||
|
vy = self.vf * np.cos(h_rad) + self.vl * np.sin(h_rad)
|
||||||
|
self.x += vx * dt
|
||||||
|
self.y += vy * dt
|
||||||
|
return self.x, self.y, self.heading_deg
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Accumulation de sweep
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class SweepAccumulator:
|
||||||
|
def __init__(self):
|
||||||
|
self.current_sweep: List[Dict] = []
|
||||||
|
self.last_angle = None
|
||||||
|
self.sweeps_done = 0
|
||||||
|
|
||||||
|
def reset(self):
|
||||||
|
self.current_sweep = []
|
||||||
|
self.last_angle = None
|
||||||
|
self.sweeps_done = 0
|
||||||
|
|
||||||
|
def add_beam(self, angle_deg: float, distance: float,
|
||||||
|
pose: Tuple[float, float, float]) -> Optional[List[Dict]]:
|
||||||
|
beam = {"angle_deg": angle_deg, "distance": distance, "pose": pose}
|
||||||
|
|
||||||
|
if (self.last_angle is not None
|
||||||
|
and self.last_angle > 180
|
||||||
|
and angle_deg < 90):
|
||||||
|
completed = self.current_sweep.copy()
|
||||||
|
self.current_sweep = [beam]
|
||||||
|
self.last_angle = angle_deg
|
||||||
|
self.sweeps_done += 1
|
||||||
|
if len(completed) >= 20:
|
||||||
|
return completed
|
||||||
|
return None
|
||||||
|
|
||||||
|
self.current_sweep.append(beam)
|
||||||
|
self.last_angle = angle_deg
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def sweep_to_world_points(sweep: List[Dict]) -> np.ndarray:
|
||||||
|
points = []
|
||||||
|
for beam in sweep:
|
||||||
|
d = beam["distance"]
|
||||||
|
if d <= 0.01:
|
||||||
|
continue
|
||||||
|
angle_rel = beam["angle_deg"]
|
||||||
|
px, py, hdg = beam["pose"]
|
||||||
|
angle_world = (hdg + angle_rel) % 360.0
|
||||||
|
a_rad = np.deg2rad(angle_world)
|
||||||
|
wx = px + d * np.sin(a_rad)
|
||||||
|
wy = py + d * np.cos(a_rad)
|
||||||
|
points.append([wx, wy])
|
||||||
|
if not points:
|
||||||
|
return np.zeros((0, 2))
|
||||||
|
return np.array(points)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# ICP 2D
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def icp_2d(
|
||||||
|
source_pts: np.ndarray,
|
||||||
|
map_pts: np.ndarray,
|
||||||
|
max_iter: int = 20,
|
||||||
|
tol: float = 1e-4,
|
||||||
|
max_dist: float = 0.5,
|
||||||
|
) -> Tuple[float, float, float, bool]:
|
||||||
|
if len(source_pts) < 5 or len(map_pts) < 5:
|
||||||
|
return 0.0, 0.0, 0.0, False
|
||||||
|
|
||||||
|
src = source_pts.copy()
|
||||||
|
tree = cKDTree(map_pts)
|
||||||
|
dx_total = 0.0
|
||||||
|
dy_total = 0.0
|
||||||
|
dtheta_total = 0.0
|
||||||
|
|
||||||
|
for _ in range(max_iter):
|
||||||
|
dists, idxs = tree.query(src, k=1, workers=-1)
|
||||||
|
mask = dists < max_dist
|
||||||
|
if mask.sum() < 5:
|
||||||
|
return dx_total, dy_total, dtheta_total, False
|
||||||
|
|
||||||
|
src_m = src[mask]
|
||||||
|
dst_m = map_pts[idxs[mask]]
|
||||||
|
c_src = src_m.mean(axis=0)
|
||||||
|
c_dst = dst_m.mean(axis=0)
|
||||||
|
A = src_m - c_src
|
||||||
|
B = dst_m - c_dst
|
||||||
|
H = A.T @ B
|
||||||
|
U, S, Vt = np.linalg.svd(H)
|
||||||
|
R = Vt.T @ U.T
|
||||||
|
if np.linalg.det(R) < 0:
|
||||||
|
Vt[-1, :] *= -1
|
||||||
|
R = Vt.T @ U.T
|
||||||
|
t = c_dst - R @ c_src
|
||||||
|
src = (R @ src.T).T + t
|
||||||
|
dx_total += t[0]
|
||||||
|
dy_total += t[1]
|
||||||
|
dtheta_total += np.arctan2(R[1, 0], R[0, 0])
|
||||||
|
if np.abs(t).max() < tol and abs(np.arctan2(R[1, 0], R[0, 0])) < tol:
|
||||||
|
return dx_total, dy_total, dtheta_total, True
|
||||||
|
|
||||||
|
return dx_total, dy_total, dtheta_total, True
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Carte incrémentale
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class IncrementalMap:
|
||||||
|
def __init__(self, voxel_size: float = 0.05):
|
||||||
|
self.points: List[np.ndarray] = []
|
||||||
|
self.voxel_size = voxel_size
|
||||||
|
self._voxel_set = set()
|
||||||
|
|
||||||
|
def reset(self):
|
||||||
|
self.points = []
|
||||||
|
self._voxel_set = set()
|
||||||
|
|
||||||
|
def add_points(self, pts: np.ndarray) -> int:
|
||||||
|
"""Retourne le nombre de nouveaux points ajoutés."""
|
||||||
|
added = 0
|
||||||
|
for pt in pts:
|
||||||
|
key = (int(pt[0] / self.voxel_size), int(pt[1] / self.voxel_size))
|
||||||
|
if key not in self._voxel_set:
|
||||||
|
self._voxel_set.add(key)
|
||||||
|
self.points.append(pt)
|
||||||
|
added += 1
|
||||||
|
return added
|
||||||
|
|
||||||
|
def get_array(self) -> np.ndarray:
|
||||||
|
if not self.points:
|
||||||
|
return np.zeros((0, 2))
|
||||||
|
return np.array(self.points)
|
||||||
|
|
||||||
|
def __len__(self):
|
||||||
|
return len(self.points)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fermeture de boucle
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class LoopClosureDetector:
|
||||||
|
def __init__(self, min_time: float = 15.0, dist_thresh: float = 0.8):
|
||||||
|
self.min_time = min_time
|
||||||
|
self.dist_thresh = dist_thresh
|
||||||
|
self.history: List[Tuple[float, float, float, int]] = []
|
||||||
|
|
||||||
|
def reset(self):
|
||||||
|
self.history = []
|
||||||
|
|
||||||
|
def add_pose(self, t: float, x: float, y: float, sweep_idx: int) -> None:
|
||||||
|
self.history.append((t, x, y, sweep_idx))
|
||||||
|
|
||||||
|
def check(self, t: float, x: float, y: float) -> Optional[int]:
|
||||||
|
for (t_old, x_old, y_old, idx_old) in self.history:
|
||||||
|
if t - t_old < self.min_time:
|
||||||
|
continue
|
||||||
|
dist = np.sqrt((x - x_old)**2 + (y - y_old)**2)
|
||||||
|
if dist < self.dist_thresh:
|
||||||
|
return idx_old
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Orchestrateur SLAM incrémental
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class IncrementalSLAM:
|
||||||
|
"""
|
||||||
|
Reçoit les enregistrements un par un via add_record().
|
||||||
|
Après chaque sweep complet → ICP + fermeture de boucle.
|
||||||
|
Expose: pose_dr, pose_corrected, map, trajectory, loop_closures.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.dr = DeadReckoning()
|
||||||
|
self.sweep_acc = SweepAccumulator()
|
||||||
|
self.imap = IncrementalMap()
|
||||||
|
self.lcd = LoopClosureDetector()
|
||||||
|
|
||||||
|
# Pose courante (x, y, heading_deg)
|
||||||
|
self.pose_dr = (0.0, 0.0, 0.0)
|
||||||
|
self.pose_corrected = (0.0, 0.0, 0.0)
|
||||||
|
|
||||||
|
# Trajectoire : liste de (t, x, y, h) — corrigée
|
||||||
|
self.trajectory: List[Tuple[float, float, float, float]] = []
|
||||||
|
|
||||||
|
# Stats
|
||||||
|
self.records_count = 0
|
||||||
|
self.sweeps_done = 0
|
||||||
|
self.loop_closures = 0
|
||||||
|
self.last_t = 0.0
|
||||||
|
|
||||||
|
# Points ajoutés au dernier sweep (pour delta live)
|
||||||
|
self._last_new_points: np.ndarray = np.zeros((0, 2))
|
||||||
|
|
||||||
|
def reset(self):
|
||||||
|
self.dr.reset()
|
||||||
|
self.sweep_acc.reset()
|
||||||
|
self.imap.reset()
|
||||||
|
self.lcd.reset()
|
||||||
|
self.pose_dr = (0.0, 0.0, 0.0)
|
||||||
|
self.pose_corrected = (0.0, 0.0, 0.0)
|
||||||
|
self.trajectory = []
|
||||||
|
self.records_count = 0
|
||||||
|
self.sweeps_done = 0
|
||||||
|
self.loop_closures = 0
|
||||||
|
self.last_t = 0.0
|
||||||
|
self._last_new_points = np.zeros((0, 2))
|
||||||
|
|
||||||
|
def add_record(self, rec: dict) -> bool:
|
||||||
|
"""
|
||||||
|
Traite un enregistrement. Retourne True si un sweep vient d'être complété
|
||||||
|
(le client /ws/live recevra alors un delta).
|
||||||
|
"""
|
||||||
|
t = rec["t"]
|
||||||
|
self.records_count += 1
|
||||||
|
self.last_t = t
|
||||||
|
|
||||||
|
# DR
|
||||||
|
x, y, h = self.dr.update(
|
||||||
|
t=t,
|
||||||
|
heading_deg=rec["heading"],
|
||||||
|
ax=rec["ax"],
|
||||||
|
ay=rec["ay"],
|
||||||
|
gz=rec["gz"],
|
||||||
|
vf=rec.get("vf"),
|
||||||
|
vl=rec.get("vl", 0.0),
|
||||||
|
)
|
||||||
|
self.pose_dr = (x, y, h)
|
||||||
|
|
||||||
|
# Sweep accumulation
|
||||||
|
sweep = self.sweep_acc.add_beam(
|
||||||
|
angle_deg=rec["ping360_angle"],
|
||||||
|
distance=rec["ping360_distance"],
|
||||||
|
pose=(x, y, h),
|
||||||
|
)
|
||||||
|
|
||||||
|
if sweep is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# --- Sweep complet ---
|
||||||
|
self.sweeps_done = self.sweep_acc.sweeps_done
|
||||||
|
new_pts = sweep_to_world_points(sweep)
|
||||||
|
|
||||||
|
# ICP si la carte n'est pas vide
|
||||||
|
map_arr = self.imap.get_array()
|
||||||
|
dx_icp = dy_icp = dtheta_icp = 0.0
|
||||||
|
if len(map_arr) >= 5 and len(new_pts) >= 5:
|
||||||
|
dx_icp, dy_icp, dtheta_icp, _ = icp_2d(new_pts, map_arr)
|
||||||
|
# Appliquer correction ICP à la pose courante
|
||||||
|
x_c = x + dx_icp
|
||||||
|
y_c = y + dy_icp
|
||||||
|
h_c = (h + np.rad2deg(dtheta_icp)) % 360
|
||||||
|
self.pose_corrected = (x_c, y_c, h_c)
|
||||||
|
# Recalculer les points corrigés pour la carte
|
||||||
|
corrected_sweep = [
|
||||||
|
{
|
||||||
|
"angle_deg": b["angle_deg"],
|
||||||
|
"distance": b["distance"],
|
||||||
|
"pose": (
|
||||||
|
b["pose"][0] + dx_icp,
|
||||||
|
b["pose"][1] + dy_icp,
|
||||||
|
b["pose"][2] + np.rad2deg(dtheta_icp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
for b in sweep
|
||||||
|
]
|
||||||
|
map_pts_new = sweep_to_world_points(corrected_sweep)
|
||||||
|
else:
|
||||||
|
self.pose_corrected = (x, y, h)
|
||||||
|
map_pts_new = new_pts
|
||||||
|
|
||||||
|
# Ajouter les points à la carte
|
||||||
|
added = self.imap.add_points(map_pts_new)
|
||||||
|
self._last_new_points = map_pts_new if added > 0 else np.zeros((0, 2))
|
||||||
|
|
||||||
|
# Trajectoire
|
||||||
|
xc, yc, hc = self.pose_corrected
|
||||||
|
self.trajectory.append((t, xc, yc, hc))
|
||||||
|
|
||||||
|
# Fermeture de boucle
|
||||||
|
loop_old_idx = self.lcd.check(t, xc, yc)
|
||||||
|
if loop_old_idx is not None:
|
||||||
|
self.loop_closures += 1
|
||||||
|
# Redistribution linéaire correction sur dernières poses
|
||||||
|
n_loop = len(self.trajectory) - loop_old_idx
|
||||||
|
if n_loop > 1:
|
||||||
|
corr = np.array([
|
||||||
|
self.trajectory[loop_old_idx][1] - xc,
|
||||||
|
self.trajectory[loop_old_idx][2] - yc,
|
||||||
|
])
|
||||||
|
for i in range(loop_old_idx, len(self.trajectory)):
|
||||||
|
alpha = (i - loop_old_idx) / n_loop
|
||||||
|
tt, xx, yy, hh = self.trajectory[i]
|
||||||
|
self.trajectory[i] = (tt, xx + alpha * corr[0], yy + alpha * corr[1], hh)
|
||||||
|
|
||||||
|
self.lcd.add_pose(t, xc, yc, len(self.trajectory) - 1)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def get_state_snapshot(self) -> dict:
|
||||||
|
"""Retourne l'état complet (pour connexion initiale /ws/live)."""
|
||||||
|
map_arr = self.imap.get_array()
|
||||||
|
return {
|
||||||
|
"type": "snapshot",
|
||||||
|
"records": self.records_count,
|
||||||
|
"sweeps": self.sweeps_done,
|
||||||
|
"loop_closures": self.loop_closures,
|
||||||
|
"last_t": self.last_t,
|
||||||
|
"pose_dr": list(self.pose_dr),
|
||||||
|
"pose_corrected": list(self.pose_corrected),
|
||||||
|
"map_points": map_arr.tolist(),
|
||||||
|
"trajectory": [list(p) for p in self.trajectory],
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_sweep_delta(self) -> dict:
|
||||||
|
"""Retourne le delta après un sweep (nouveaux points + pose actuelle)."""
|
||||||
|
return {
|
||||||
|
"type": "delta",
|
||||||
|
"records": self.records_count,
|
||||||
|
"sweeps": self.sweeps_done,
|
||||||
|
"loop_closures": self.loop_closures,
|
||||||
|
"last_t": self.last_t,
|
||||||
|
"pose_dr": list(self.pose_dr),
|
||||||
|
"pose_corrected": list(self.pose_corrected),
|
||||||
|
"new_map_points": self._last_new_points.tolist(),
|
||||||
|
"trajectory_tail": [list(p) for p in self.trajectory[-5:]],
|
||||||
|
}
|
||||||
487
server/static/index.html
Normal file
487
server/static/index.html
Normal file
@@ -0,0 +1,487 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Moulin Mapper — Live</title>
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body {
|
||||||
|
background: #0d1117;
|
||||||
|
color: #c9d1d9;
|
||||||
|
font-family: 'Consolas', 'Monaco', monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
header {
|
||||||
|
background: #161b22;
|
||||||
|
border-bottom: 1px solid #30363d;
|
||||||
|
padding: 8px 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
header h1 { font-size: 15px; font-weight: 600; color: #58a6ff; letter-spacing: 1px; }
|
||||||
|
#conn-dot { width: 10px; height: 10px; border-radius: 50%; background: #f85149; flex-shrink: 0; }
|
||||||
|
#conn-dot.ok { background: #3fb950; }
|
||||||
|
#conn-label { font-size: 12px; }
|
||||||
|
.main {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
/* Panneau gauche: canvas */
|
||||||
|
#canvas-wrap {
|
||||||
|
flex: 1;
|
||||||
|
position: relative;
|
||||||
|
background: #010409;
|
||||||
|
}
|
||||||
|
canvas {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
/* Panneau droit: stats + contrôles */
|
||||||
|
aside {
|
||||||
|
width: 260px;
|
||||||
|
background: #161b22;
|
||||||
|
border-left: 1px solid #30363d;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.section {
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-bottom: 1px solid #21262d;
|
||||||
|
}
|
||||||
|
.section h2 {
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
color: #8b949e;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.stat-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 3px 0;
|
||||||
|
border-bottom: 1px solid #0d1117;
|
||||||
|
}
|
||||||
|
.stat-row:last-child { border-bottom: none; }
|
||||||
|
.stat-label { color: #8b949e; }
|
||||||
|
.stat-value { color: #f0f6fc; font-weight: 600; }
|
||||||
|
.stat-value.accent { color: #58a6ff; }
|
||||||
|
.stat-value.warn { color: #e3b341; }
|
||||||
|
input[type=text] {
|
||||||
|
width: 100%;
|
||||||
|
background: #0d1117;
|
||||||
|
border: 1px solid #30363d;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #c9d1d9;
|
||||||
|
padding: 5px 8px;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
input[type=text]:focus { outline: none; border-color: #58a6ff; }
|
||||||
|
button {
|
||||||
|
width: 100%;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.btn-danger {
|
||||||
|
background: #21262d;
|
||||||
|
border-color: #f85149;
|
||||||
|
color: #f85149;
|
||||||
|
}
|
||||||
|
.btn-danger:hover { background: #3d1c1c; }
|
||||||
|
.btn-primary {
|
||||||
|
background: #21262d;
|
||||||
|
border-color: #58a6ff;
|
||||||
|
color: #58a6ff;
|
||||||
|
text-decoration: none;
|
||||||
|
display: block;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.btn-primary:hover { background: #1c2a3d; }
|
||||||
|
#log {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #8b949e;
|
||||||
|
max-height: 120px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 8px 14px;
|
||||||
|
flex: 1;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
#log p { margin-bottom: 2px; line-height: 1.4; }
|
||||||
|
#log p.err { color: #f85149; }
|
||||||
|
#log p.ok { color: #3fb950; }
|
||||||
|
.legend {
|
||||||
|
display: flex; flex-direction: column; gap: 4px;
|
||||||
|
font-size: 11px; color: #8b949e;
|
||||||
|
}
|
||||||
|
.legend-item { display: flex; align-items: center; gap: 6px; }
|
||||||
|
.leg-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<header>
|
||||||
|
<h1>MOULIN MAPPER</h1>
|
||||||
|
<div id="conn-dot"></div>
|
||||||
|
<span id="conn-label">Déconnecté</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="main">
|
||||||
|
<div id="canvas-wrap">
|
||||||
|
<canvas id="map-canvas"></canvas>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside>
|
||||||
|
<div class="section">
|
||||||
|
<h2>Stats</h2>
|
||||||
|
<div class="stat-row"><span class="stat-label">Enregistrements</span><span class="stat-value accent" id="s-records">0</span></div>
|
||||||
|
<div class="stat-row"><span class="stat-label">Sweeps</span><span class="stat-value" id="s-sweeps">0</span></div>
|
||||||
|
<div class="stat-row"><span class="stat-label">Points carte</span><span class="stat-value" id="s-points">0</span></div>
|
||||||
|
<div class="stat-row"><span class="stat-label">Fermetures boucle</span><span class="stat-value warn" id="s-loops">0</span></div>
|
||||||
|
<div class="stat-row"><span class="stat-label">Dernier t</span><span class="stat-value" id="s-t">—</span></div>
|
||||||
|
<div class="stat-row"><span class="stat-label">Débit</span><span class="stat-value" id="s-debit">0</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h2>Pose ROV</h2>
|
||||||
|
<div class="stat-row"><span class="stat-label">x (m)</span><span class="stat-value" id="s-x">0.00</span></div>
|
||||||
|
<div class="stat-row"><span class="stat-label">y (m)</span><span class="stat-value" id="s-y">0.00</span></div>
|
||||||
|
<div class="stat-row"><span class="stat-label">cap (°)</span><span class="stat-value" id="s-h">0.0</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h2>Légende</h2>
|
||||||
|
<div class="legend">
|
||||||
|
<div class="legend-item"><div class="leg-dot" style="background:#58a6ff;"></div><span>Points carte (murs)</span></div>
|
||||||
|
<div class="legend-item"><div class="leg-dot" style="background:#3fb950;"></div><span>Trajectoire corrigée</span></div>
|
||||||
|
<div class="legend-item"><div class="leg-dot" style="background:#f0f6fc; border-radius:0;"></div><span>ROV courant</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h2>Contrôles</h2>
|
||||||
|
<input type="text" id="token-input" placeholder="Token (défaut: moulin-2026)" value="moulin-2026">
|
||||||
|
<button class="btn-danger" onclick="resetSession()">Reset session</button>
|
||||||
|
<a id="ply-link" class="btn-primary" href="./cloud.ply" download="moulin_cloud.ply">Télécharger nuage .ply</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section" style="flex:1;padding:0;">
|
||||||
|
<div id="log"></div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Données en mémoire
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
let mapPoints = []; // [[x, y], ...]
|
||||||
|
let trajectory = []; // [[t, x, y, h], ...]
|
||||||
|
let poseCorrect = [0, 0, 0];
|
||||||
|
let stats = { records: 0, sweeps: 0, loop_closures: 0, last_t: 0, debit: 0 };
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Canvas + viewport
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const canvas = document.getElementById('map-canvas');
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
|
||||||
|
let viewX = 4.0; // centre monde visible
|
||||||
|
let viewY = 4.0;
|
||||||
|
let viewScale = 50; // px/m
|
||||||
|
let dragging = false;
|
||||||
|
let dragStart = null;
|
||||||
|
let dragView = null;
|
||||||
|
|
||||||
|
function worldToCanvas(wx, wy) {
|
||||||
|
const cx = canvas.width / 2;
|
||||||
|
const cy = canvas.height / 2;
|
||||||
|
return [
|
||||||
|
cx + (wx - viewX) * viewScale,
|
||||||
|
cy - (wy - viewY) * viewScale,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function resizeCanvas() {
|
||||||
|
const wrap = document.getElementById('canvas-wrap');
|
||||||
|
canvas.width = wrap.clientWidth;
|
||||||
|
canvas.height = wrap.clientHeight;
|
||||||
|
draw();
|
||||||
|
}
|
||||||
|
window.addEventListener('resize', resizeCanvas);
|
||||||
|
|
||||||
|
// Zoom molette
|
||||||
|
canvas.addEventListener('wheel', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const factor = e.deltaY > 0 ? 0.85 : 1.18;
|
||||||
|
viewScale = Math.max(5, Math.min(500, viewScale * factor));
|
||||||
|
draw();
|
||||||
|
}, { passive: false });
|
||||||
|
|
||||||
|
// Pan souris
|
||||||
|
canvas.addEventListener('mousedown', (e) => {
|
||||||
|
dragging = true;
|
||||||
|
dragStart = [e.clientX, e.clientY];
|
||||||
|
dragView = [viewX, viewY];
|
||||||
|
});
|
||||||
|
canvas.addEventListener('mousemove', (e) => {
|
||||||
|
if (!dragging) return;
|
||||||
|
const dx = (e.clientX - dragStart[0]) / viewScale;
|
||||||
|
const dy = (e.clientY - dragStart[1]) / viewScale;
|
||||||
|
viewX = dragView[0] - dx;
|
||||||
|
viewY = dragView[1] + dy;
|
||||||
|
draw();
|
||||||
|
});
|
||||||
|
canvas.addEventListener('mouseup', () => { dragging = false; });
|
||||||
|
canvas.addEventListener('mouseleave', () => { dragging = false; });
|
||||||
|
|
||||||
|
function draw() {
|
||||||
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
|
||||||
|
// Grille légère
|
||||||
|
ctx.strokeStyle = '#1c2128';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
const gridStep = 1; // 1m
|
||||||
|
const xMin = viewX - canvas.width / (2 * viewScale) - gridStep;
|
||||||
|
const xMax = viewX + canvas.width / (2 * viewScale) + gridStep;
|
||||||
|
const yMin = viewY - canvas.height / (2 * viewScale) - gridStep;
|
||||||
|
const yMax = viewY + canvas.height / (2 * viewScale) + gridStep;
|
||||||
|
for (let gx = Math.floor(xMin); gx <= xMax; gx += gridStep) {
|
||||||
|
ctx.beginPath();
|
||||||
|
const [px, ] = worldToCanvas(gx, 0);
|
||||||
|
ctx.moveTo(px, 0); ctx.lineTo(px, canvas.height);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
for (let gy = Math.floor(yMin); gy <= yMax; gy += gridStep) {
|
||||||
|
ctx.beginPath();
|
||||||
|
const [, py] = worldToCanvas(0, gy);
|
||||||
|
ctx.moveTo(0, py); ctx.lineTo(canvas.width, py);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Axes origine
|
||||||
|
ctx.strokeStyle = '#21262d';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
const [ox, oy] = worldToCanvas(0, 0);
|
||||||
|
ctx.beginPath(); ctx.moveTo(ox, 0); ctx.lineTo(ox, canvas.height); ctx.stroke();
|
||||||
|
ctx.beginPath(); ctx.moveTo(0, oy); ctx.lineTo(canvas.width, oy); ctx.stroke();
|
||||||
|
|
||||||
|
// Points carte (murs)
|
||||||
|
const ptRadius = Math.max(1, viewScale * 0.04);
|
||||||
|
ctx.fillStyle = 'rgba(88, 166, 255, 0.7)';
|
||||||
|
for (const [wx, wy] of mapPoints) {
|
||||||
|
const [px, py] = worldToCanvas(wx, wy);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(px, py, ptRadius, 0, 2 * Math.PI);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trajectoire
|
||||||
|
if (trajectory.length > 1) {
|
||||||
|
ctx.strokeStyle = 'rgba(63, 185, 80, 0.8)';
|
||||||
|
ctx.lineWidth = 1.5;
|
||||||
|
ctx.beginPath();
|
||||||
|
const [x0, y0] = worldToCanvas(trajectory[0][1], trajectory[0][2]);
|
||||||
|
ctx.moveTo(x0, y0);
|
||||||
|
for (let i = 1; i < trajectory.length; i++) {
|
||||||
|
const [px, py] = worldToCanvas(trajectory[i][1], trajectory[i][2]);
|
||||||
|
ctx.lineTo(px, py);
|
||||||
|
}
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ROV courant
|
||||||
|
const [rx, ry] = worldToCanvas(poseCorrect[0], poseCorrect[1]);
|
||||||
|
const hRad = (90 - poseCorrect[2]) * Math.PI / 180; // cap → angle canvas
|
||||||
|
const arrowLen = Math.max(8, viewScale * 0.3);
|
||||||
|
ctx.save();
|
||||||
|
ctx.translate(rx, ry);
|
||||||
|
ctx.rotate(-hRad);
|
||||||
|
ctx.fillStyle = '#f0f6fc';
|
||||||
|
ctx.strokeStyle = '#f0f6fc';
|
||||||
|
ctx.lineWidth = 1.5;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(0, -arrowLen);
|
||||||
|
ctx.lineTo(arrowLen * 0.5, arrowLen * 0.5);
|
||||||
|
ctx.lineTo(0, 0);
|
||||||
|
ctx.lineTo(-arrowLen * 0.5, arrowLen * 0.5);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fill();
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Stats UI
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function updateStats(data) {
|
||||||
|
if (data.records !== undefined) {
|
||||||
|
stats.records = data.records;
|
||||||
|
stats.sweeps = data.sweeps;
|
||||||
|
stats.loop_closures = data.loop_closures;
|
||||||
|
stats.last_t = data.last_t;
|
||||||
|
stats.debit = data.debit || 0;
|
||||||
|
}
|
||||||
|
document.getElementById('s-records').textContent = stats.records;
|
||||||
|
document.getElementById('s-sweeps').textContent = stats.sweeps;
|
||||||
|
document.getElementById('s-loops').textContent = stats.loop_closures;
|
||||||
|
document.getElementById('s-t').textContent = stats.last_t ? stats.last_t.toFixed(1) + 's' : '—';
|
||||||
|
document.getElementById('s-debit').textContent = stats.debit.toFixed(1) + ' rec/s';
|
||||||
|
document.getElementById('s-points').textContent = mapPoints.length;
|
||||||
|
|
||||||
|
if (data.pose_corrected) {
|
||||||
|
poseCorrect = data.pose_corrected;
|
||||||
|
document.getElementById('s-x').textContent = poseCorrect[0].toFixed(2);
|
||||||
|
document.getElementById('s-y').textContent = poseCorrect[1].toFixed(2);
|
||||||
|
document.getElementById('s-h').textContent = poseCorrect[2].toFixed(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Gestion message WS
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function handleMessage(data) {
|
||||||
|
if (data.type === 'snapshot') {
|
||||||
|
// État complet
|
||||||
|
mapPoints = data.map_points || [];
|
||||||
|
trajectory = data.trajectory || [];
|
||||||
|
poseCorrect = data.pose_corrected || [0, 0, 0];
|
||||||
|
updateStats(data);
|
||||||
|
draw();
|
||||||
|
log('Snapshot reçu — ' + mapPoints.length + ' pts, ' + trajectory.length + ' poses', 'ok');
|
||||||
|
} else if (data.type === 'delta') {
|
||||||
|
// Nouveaux points
|
||||||
|
const newPts = data.new_map_points || [];
|
||||||
|
for (const pt of newPts) mapPoints.push(pt);
|
||||||
|
// Trajectoire : ajout des dernières poses
|
||||||
|
const tail = data.trajectory_tail || [];
|
||||||
|
for (const pose of tail) {
|
||||||
|
// Évite les doublons par t
|
||||||
|
if (trajectory.length === 0 || trajectory[trajectory.length - 1][0] < pose[0]) {
|
||||||
|
trajectory.push(pose);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updateStats(data);
|
||||||
|
draw();
|
||||||
|
} else if (data.type === 'reset') {
|
||||||
|
mapPoints = [];
|
||||||
|
trajectory = [];
|
||||||
|
poseCorrect = [0, 0, 0];
|
||||||
|
stats = { records: 0, sweeps: 0, loop_closures: 0, last_t: 0, debit: 0 };
|
||||||
|
updateStats({});
|
||||||
|
draw();
|
||||||
|
log('Session réinitialisée', 'ok');
|
||||||
|
} else if (data.type === 'ping') {
|
||||||
|
// keepalive silencieux
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Connexion WS /ws/live
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
let ws = null;
|
||||||
|
let reconnectTimer = null;
|
||||||
|
|
||||||
|
function wsConnect() {
|
||||||
|
const base = location.pathname.replace(/\/[^/]*$/, '').replace(/\/$/, '');
|
||||||
|
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
const url = proto + '//' + location.host + base + '/ws/live';
|
||||||
|
|
||||||
|
log('Connexion ' + url + '…');
|
||||||
|
ws = new WebSocket(url);
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
document.getElementById('conn-dot').classList.add('ok');
|
||||||
|
document.getElementById('conn-label').textContent = 'Connecté';
|
||||||
|
log('WS ouvert', 'ok');
|
||||||
|
if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onmessage = (e) => {
|
||||||
|
try {
|
||||||
|
handleMessage(JSON.parse(e.data));
|
||||||
|
} catch (err) {
|
||||||
|
console.error('parse WS:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
document.getElementById('conn-dot').classList.remove('ok');
|
||||||
|
document.getElementById('conn-label').textContent = 'Déconnecté — reconnexion dans 3s…';
|
||||||
|
log('WS fermé, tentative dans 3s…', 'err');
|
||||||
|
reconnectTimer = setTimeout(wsConnect, 3000);
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = (e) => {
|
||||||
|
log('Erreur WS', 'err');
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Reset session
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
async function resetSession() {
|
||||||
|
const token = document.getElementById('token-input').value.trim() || 'moulin-2026';
|
||||||
|
const base = location.pathname.replace(/\/[^/]*$/, '').replace(/\/$/, '');
|
||||||
|
try {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('token', token);
|
||||||
|
const resp = await fetch(base + '/session/reset', { method: 'POST', body: form });
|
||||||
|
if (resp.ok) {
|
||||||
|
log('Reset OK', 'ok');
|
||||||
|
} else {
|
||||||
|
log('Reset KO: ' + resp.status, 'err');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
log('Reset erreur: ' + e.message, 'err');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Bouton PLY — chemin relatif
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
(function() {
|
||||||
|
const base = location.pathname.replace(/\/[^/]*$/, '').replace(/\/$/, '');
|
||||||
|
document.getElementById('ply-link').href = base + '/cloud.ply';
|
||||||
|
})();
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Log
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function log(msg, cls) {
|
||||||
|
const el = document.getElementById('log');
|
||||||
|
const p = document.createElement('p');
|
||||||
|
if (cls) p.className = cls;
|
||||||
|
const now = new Date();
|
||||||
|
p.textContent = now.toTimeString().slice(0, 8) + ' ' + msg;
|
||||||
|
el.appendChild(p);
|
||||||
|
el.scrollTop = el.scrollHeight;
|
||||||
|
// Garde les 100 dernières lignes
|
||||||
|
while (el.children.length > 100) el.removeChild(el.firstChild);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Init
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
resizeCanvas();
|
||||||
|
wsConnect();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
4225
web/assets/cloud.ply
Normal file
4225
web/assets/cloud.ply
Normal file
File diff suppressed because it is too large
Load Diff
2110
web/assets/map_2d.csv
Normal file
2110
web/assets/map_2d.csv
Normal file
File diff suppressed because it is too large
Load Diff
2439
web/assets/run_L.jsonl
Normal file
2439
web/assets/run_L.jsonl
Normal file
File diff suppressed because it is too large
Load Diff
2440
web/assets/run_L_truth.csv
Normal file
2440
web/assets/run_L_truth.csv
Normal file
File diff suppressed because it is too large
Load Diff
31
web/assets/trajectory.csv
Normal file
31
web/assets/trajectory.csv
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
t,x_dr,y_dr,x_corr,y_corr
|
||||||
|
4.0000,0.0052,1.2770,0.0052,1.2770
|
||||||
|
8.0000,0.0099,2.5682,0.0186,2.5028
|
||||||
|
12.0000,0.0150,3.7379,0.0032,3.5944
|
||||||
|
16.0000,-0.0363,3.7869,-0.0636,3.5496
|
||||||
|
20.0000,-1.2863,3.7897,-1.2616,3.5461
|
||||||
|
24.0000,-2.5763,3.7952,-2.4599,3.5471
|
||||||
|
28.0000,-2.7656,3.8377,-2.6040,3.5255
|
||||||
|
32.0000,-2.7679,4.8392,-2.5176,4.4853
|
||||||
|
36.0000,-2.7505,6.1210,-2.5054,5.5785
|
||||||
|
40.0000,-2.7465,7.0774,-2.4440,6.4784
|
||||||
|
44.0000,-2.8020,7.1050,-2.3972,6.2123
|
||||||
|
48.0000,-2.8469,7.0197,-2.4795,6.0262
|
||||||
|
52.0000,-2.8601,5.7395,-2.5417,4.6874
|
||||||
|
56.0000,-2.8680,4.4624,-2.5370,3.4324
|
||||||
|
60.0000,-2.8839,3.1747,-2.3394,2.7384
|
||||||
|
64.0000,-2.8918,1.8854,-2.4141,1.5799
|
||||||
|
68.0000,-2.8906,1.1199,-2.4494,0.8736
|
||||||
|
72.0000,-2.4417,1.0912,-2.0733,1.0443
|
||||||
|
76.0000,-1.1468,1.0653,-0.8350,1.0251
|
||||||
|
80.0000,0.1292,1.0489,0.3113,0.9984
|
||||||
|
84.0000,1.4097,1.0307,1.5811,0.9940
|
||||||
|
88.0000,1.4998,1.0806,1.6091,1.0336
|
||||||
|
92.0000,1.5174,2.1988,1.5662,2.1307
|
||||||
|
96.0000,1.5338,3.4865,1.6061,3.3106
|
||||||
|
100.0000,1.5112,3.8156,1.5762,3.6191
|
||||||
|
104.0000,0.6588,3.8443,0.8076,3.6364
|
||||||
|
108.0000,-0.1266,3.8464,0.0730,3.6349
|
||||||
|
112.0000,-0.1646,3.4383,0.1422,3.2418
|
||||||
|
116.0000,-0.1861,2.1536,0.1467,1.9940
|
||||||
|
120.0000,-0.2090,0.8700,0.1515,0.7458
|
||||||
|
Reference in New Issue
Block a user