- English: This page isn't translated to English.
Spécification du flux LSL « Transforms »
Version 1.0 · Document d'interface · Flux LSL de transforms 3D multi-objets (position, rotation, échelle)
1. Objet du document
Ce document décrit précisément le flux LSL (Lab Streaming Layer) que l'émetteur doit produire pour alimenter l'application de réception. Il couvre l'identification du stream, le format des échantillons, l'organisation des canaux, le repère et les unités, le bloc de métadonnées XML (desc), les règles d'émission et le comportement du récepteur.
Le flux transporte les transforms (position, rotation, échelle) d'un nombre quelconque d'objets 3D. Le récepteur ne connaît pas ce nombre à l'avance : il le découvre dans les métadonnées à la connexion. Une implémentation de référence, LslTransformSim, produit exactement le flux décrit ici (voir section 14).
2. Vue d'ensemble
- Un seul stream LSL contient tous les objets.
- Chaque échantillon est un vecteur de N × 10 valeurs float32, soit 10 canaux par objet (N = nombre d'objets).
- Tous les objets d'un échantillon sont synchrones (même instant).
- Le header du stream (
desc) décrit la structure : nombre d'objets, nom et type d'entité de chaque objet, rôle de chaque canal. - La structure (nombre d'objets, ordre, noms, types) est fixée à la création du stream.
3. Prérequis techniques
- liblsl 1.16 ou supérieure, via n'importe quel binding : C, C++, C#, Python, etc.
- Réseau : émetteur et récepteur sur le même sous-réseau. LSL utilise par défaut le multicast UDP sur le port 16571 pour la découverte, puis des connexions sur les ports 16572 et suivants pour les données. Ces ports doivent être autorisés dans le pare-feu.
4. Identification du stream (StreamInfo)
| Propriété | Valeur | Remarque |
|---|---|---|
type |
Transforms |
Valeur exacte, sensible à la casse. C'est le critère de découverte du récepteur. |
name |
TransformSim (par défaut) |
Libre, mais à convenir si le récepteur filtre par nom. |
channel_count |
N × 10 | N = nombre d'objets. |
nominal_srate |
ex. 60.0 |
Cadence réelle d'émission en Hz. Utiliser 0 (IRREGULAR_RATE) seulement si la cadence est variable. |
channel_format |
float32 |
Format de référence, validé. |
source_id |
chaîne unique | ex. transformsim-3fa9c21b. Stable pour une même source ; à changer si la structure change. |
5. Format des échantillons
Les canaux sont organisés objet par objet : les 10 canaux de l'objet 0, puis ceux de l'objet 1, etc.
Index du canal = rang_de_l_objet × 10 + indice_de_composante
| Indice | Composante (type dans le desc) |
Description | Unité |
|---|---|---|---|
| 0 | PosX |
Position X | mètres |
| 1 | PosY |
Position Y | mètres |
| 2 | PosZ |
Position Z | mètres |
| 3 | RotX |
Quaternion x | sans unité |
| 4 | RotY |
Quaternion y | sans unité |
| 5 | RotZ |
Quaternion z | sans unité |
| 6 | RotW |
Quaternion w | sans unité |
| 7 | ScaleX |
Échelle X | facteur |
| 8 | ScaleY |
Échelle Y | facteur |
| 9 | ScaleZ |
Échelle Z | facteur |
Pour N objets :
| Canaux | Contenu |
|---|---|
| 0 à 9 | Objet de rang 0 |
| 10 à 19 | Objet de rang 1 |
| … | … |
| (N−1)×10 à N×10−1 | Objet de rang N−1 |
Contraintes sur les valeurs :
- Toutes les valeurs sont finies (jamais de NaN ni d'infini).
- Le quaternion est unitaire. Un quaternion entièrement nul (0, 0, 0, 0) est considéré comme invalide.
- L'échelle est strictement positive ; 1,0 correspond à la taille native.
- Les 10 composantes sont toutes obligatoires pour chaque objet.
6. Repère, unités et conventions
| Élément | Convention |
|---|---|
| Repère | Main gauche, Y vers le haut : X vers la droite, Y vers le haut, Z vers l'avant (unity_left_handed_y_up). |
| Unité de position | Mètre. |
| Rotation | Quaternion, ordre x, y, z, w, orientation absolue de l'objet dans le repère commun. Pas d'angles d'Euler. |
| Orientation de référence | Quaternion identité (0, 0, 0, 1) : l'objet regarde vers +Z, son haut est +Y. |
| Échelle | Facteur multiplicatif sans unité sur chaque axe local ; (1, 1, 1) = taille native. |
| Hiérarchie | Aucune. Tous les objets sont exprimés dans un même repère commun, sans parent. |
Exemple : une rotation d'angle θ autour de l'axe vertical Y s'écrit (0, sin(θ/2), 0, cos(θ/2)). Le simulateur oriente ses objets dans leur sens de déplacement avec θ = atan2(vx, vz).
Conversion depuis un autre moteur
La conversion vers ce repère est à la charge de l'émetteur. À titre d'exemple, pour un moteur en main gauche, Z vers le haut, X vers l'avant, Y vers la droite, en centimètres (convention d'Unreal Engine), la correspondance est une permutation circulaire des axes :
| Donnée | Formule (source → flux) |
|---|---|
| Position | PosX = Y_src / 100 ; PosY = Z_src / 100 ; PosZ = X_src / 100 |
| Quaternion | RotX = qy_src ; RotY = qz_src ; RotZ = qx_src ; RotW = qw_src |
| Échelle | ScaleX = sy_src ; ScaleY = sz_src ; ScaleZ = sx_src |
À valider par un test visuel avant intégration : déplacer un objet de 100 cm vers l'avant de la source doit donner +1 m sur Z dans le flux, et une rotation de 90° vers la droite (vue de dessus) doit donner le quaternion (0, 0,7071, 0, 0,7071).
7. Métadonnées (desc)
Le bloc desc du StreamInfo contient la description complète du stream. Il doit être entièrement rempli avant la création de l'outlet.
7.1 Structure complète
<desc>
<coordinate_system>unity_left_handed_y_up</coordinate_system>
<quaternion_order>xyzw</quaternion_order>
<position_unit>meters</position_unit>
<object_count>N</object_count>
<objects>
<object>
<id>0</id>
<name>Enemy_00</name>
<entity_type>Enemy</entity_type>
</object>
<!-- ... un élément <object> par objet, dans l'ordre des canaux -->
</objects>
<channels>
<channel>
<label>Enemy_00_PosX</label>
<unit>meters</unit>
<type>PosX</type>
<object>Enemy_00</object>
<entity_type>Enemy</entity_type>
</channel>
<!-- ... 10 éléments <channel> par objet, N × 10 au total, dans l'ordre des canaux -->
</channels>
</desc>
7.2 Champs globaux
| Champ | Valeur | Rôle |
|---|---|---|
coordinate_system |
unity_left_handed_y_up |
Déclare le repère. Valeur exacte, obligatoire. |
quaternion_order |
xyzw |
Déclare l'ordre des composantes du quaternion. Obligatoire. |
position_unit |
meters |
Déclare l'unité de position. Obligatoire. |
object_count |
entier N | Doit être égal au nombre d'éléments <object>. Obligatoire. |
7.3 Annuaire des objets : objects/object
| Champ | Valeur | Règles |
|---|---|---|
id |
entier de 0 à N−1 | Unique, croissant, égal au rang de l'objet (donc à son bloc de canaux). |
name |
chaîne | Unique dans le stream, sensible à la casse. Caractères recommandés : A-Z a-z 0-9 _ -, 64 caractères maximum. Sert de nom à l'objet côté récepteur et de clé de liaison avec channel/object. |
entity_type |
chaîne | Type d'entité (voir section 8). Constant pendant la vie du stream. |
7.4 Description des canaux : channels/channel
| Champ | Valeur | Règles |
|---|---|---|
label |
<name>_<type> |
Ex. Enemy_00_PosX. Unique dans le stream. Informatif. |
unit |
meters, quaternion ou ratio |
meters pour PosX/Y/Z, quaternion pour RotX/Y/Z/W, ratio pour ScaleX/Y/Z. |
type |
l'une des 10 composantes | PosX PosY PosZ RotX RotY RotZ RotW ScaleX ScaleY ScaleZ. C'est ce champ qui est interprété. |
object |
name de l'objet |
Doit correspondre exactement à un objects/object/name. Interprété par le récepteur. |
entity_type |
idem l'objet | Doit être identique à celui de l'objet. |
7.5 Règles impératives
- Le i-ème élément
<channel>décrit le canal d'indice i. Le récepteur déduit l'indice du canal de la position de l'élément dans la liste : l'ordre est donc essentiel. - Il y a exactement N × 10 éléments
<channel>, et chaque couple (object,type) apparaît une seule fois. - L'ordre des objets dans
<objects>est le même que l'ordre des blocs de 10 canaux dans<channels>. - Les champs sont en texte brut UTF-8 (ASCII recommandé), sans espace en début ni en fin de valeur.
- Des champs supplémentaires peuvent être ajoutés ; ils sont ignorés par le récepteur.
8. Types d'entités
Chaque objet porte un type d'entité, exprimé dans entity_type. Liste de référence :
| Valeur | Signification |
|---|---|
Enemy |
Entité hostile ou adverse |
Target |
Cible |
Civil |
Civil |
TeamMate |
Allié ou coéquipier |
Neutral |
Entité neutre |
La casse doit être respectée exactement (TeamMate, avec un M majuscule). La liste est ouverte : d'autres valeurs peuvent être introduites, à communiquer à l'avance pour que l'application de réception sache les traiter. Une valeur vide est interprétée comme Unknown.
9. Règles d'émission
- Échantillon complet : un
push_samplepar pas de temps, contenant les N objets. Jamais d'échantillon partiel. - Cadence : constante de préférence (référence : 60 Hz).
nominal_sratedoit refléter la cadence réelle. - Horodatage : horodatage par défaut de liblsl (heure LSL courante), ou horodatage explicite obtenu avec
local_clock()de liblsl au moment de la mesure. Les horodatages doivent être croissants. Ne pas utiliser l'horloge du moteur de rendu. - Structure statique : nombre d'objets, ordre, noms et types ne changent pas pendant la vie du stream. Pour modifier la structure, fermer le stream puis en créer un nouveau (avec un nouveau
source_id) ; le récepteur doit alors se reconnecter. - Objets toujours présents : un objet ne disparaît pas du vecteur. S'il devient inactif, continuer d'émettre sa dernière transform connue (la gestion d'objets absents n'est pas définie dans cette version).
- Démarrage : remplir le
desc, créer l'outlet, puis émettre en continu. Le récepteur peut se connecter à tout moment. - Arrêt : détruire proprement l'outlet.
10. Exemple complet (2 objets)
StreamInfo : name = TransformSim, type = Transforms, channel_count = 20, nominal_srate = 60, channel_format = float32.
Desc (abrégé, 2 des 20 éléments <channel> sont montrés) :
<desc>
<coordinate_system>unity_left_handed_y_up</coordinate_system>
<quaternion_order>xyzw</quaternion_order>
<position_unit>meters</position_unit>
<object_count>2</object_count>
<objects>
<object><id>0</id><name>Enemy_00</name><entity_type>Enemy</entity_type></object>
<object><id>1</id><name>Civil_01</name><entity_type>Civil</entity_type></object>
</objects>
<channels>
<channel><label>Enemy_00_PosX</label><unit>meters</unit><type>PosX</type><object>Enemy_00</object><entity_type>Enemy</entity_type></channel>
<channel><label>Enemy_00_PosY</label><unit>meters</unit><type>PosY</type><object>Enemy_00</object><entity_type>Enemy</entity_type></channel>
<!-- ... jusqu'à Civil_01_ScaleZ (canal 19) -->
</channels>
</desc>
Un échantillon (20 valeurs) :
| Canaux | Objet | PosX PosY PosZ | RotX RotY RotZ RotW | ScaleX ScaleY ScaleZ |
|---|---|---|---|---|
| 0 à 9 | Enemy_00 |
2.5, 0.5, −1.0 | 0, 0.7071, 0, 0.7071 | 1, 1, 1 |
| 10 à 19 | Civil_01 |
−4.0, 0.5, 3.2 | 0, 0, 0, 1 | 1, 1, 1 |
Soit le vecteur : [2.5, 0.5, -1.0, 0, 0.7071, 0, 0.7071, 1, 1, 1, -4.0, 0.5, 3.2, 0, 0, 0, 1, 1, 1, 1].
11. Comportement du récepteur
Ces points sont à connaître pour comprendre ce que l'application fait du flux :
- Elle découvre le stream par son
type(Transforms). - À la connexion, elle lit une seule fois le header complet (
desc). La structure n'est donc pas relue pendant la vie du stream. - Les objets sont déterminés à partir de
objects/object(nom, type d'entité), puis chaque canal est rattaché à son objet et à sa composante grâce aux champsobjectettypedechannels/channel. Le champlabeln'est pas interprété. - Un objet 3D est créé dynamiquement pour chaque nom d'objet distinct.
- La comparaison du champ
typeignore la casse, mais il faut écrire les valeurs exactes. - Valeurs par défaut si une composante est absente : position 0, rotation identité (w = 1), échelle 1.
- Un quaternion nul est remplacé par l'identité ; tout quaternion est normalisé à la réception.
- Mode de secours (à ne pas utiliser) : si aucun canal ne porte de champ
object, le récepteur suppose 10 canaux par objet dans l'ordre standard, avec des objets nommésdyn_object_iet sans type d'entité.
12. Exemple de code émetteur (C++, liblsl)
#include <lsl_cpp.h>
#include <string>
#include <vector>
struct Obj { std::string name, kind; };
lsl::stream_outlet* create_outlet(const std::vector<Obj>& objs, double srate) {
const char* types[10] = {"PosX","PosY","PosZ","RotX","RotY","RotZ","RotW","ScaleX","ScaleY","ScaleZ"};
const char* units[10] = {"meters","meters","meters","quaternion","quaternion","quaternion","quaternion","ratio","ratio","ratio"};
int n = (int)objs.size();
lsl::stream_info info("TransformSim", "Transforms", n * 10, srate, lsl::cf_float32, "mysource-001");
lsl::xml_element desc = info.desc();
desc.append_child_value("coordinate_system", "unity_left_handed_y_up");
desc.append_child_value("quaternion_order", "xyzw");
desc.append_child_value("position_unit", "meters");
desc.append_child_value("object_count", std::to_string(n));
lsl::xml_element objects = desc.append_child("objects");
for (int i = 0; i < n; ++i) {
lsl::xml_element e = objects.append_child("object");
e.append_child_value("id", std::to_string(i));
e.append_child_value("name", objs[i].name);
e.append_child_value("entity_type", objs[i].kind);
}
lsl::xml_element channels = desc.append_child("channels");
for (const Obj& ob : objs)
for (int s = 0; s < 10; ++s) {
lsl::xml_element c = channels.append_child("channel");
c.append_child_value("label", ob.name + "_" + types[s]);
c.append_child_value("unit", units[s]);
c.append_child_value("type", types[s]);
c.append_child_value("object", ob.name);
c.append_child_value("entity_type", ob.kind);
}
return new lsl::stream_outlet(info); // le desc doit être rempli AVANT
}
// À chaque pas de temps :
// std::vector<float> sample(n * 10); // remplir selon la section 5
// outlet->push_sample(sample);
13. Validation et recette
Le script Python suivant (module pylsl) vérifie automatiquement la cohérence du flux émis :
import xml.etree.ElementTree as ET
from pylsl import StreamInlet, resolve_byprop
TYPES = ['PosX','PosY','PosZ','RotX','RotY','RotZ','RotW','ScaleX','ScaleY','ScaleZ']
streams = resolve_byprop('type', 'Transforms', timeout=5)
inlet = StreamInlet(streams[0])
info = inlet.info() # header complet, avec le desc
desc = ET.fromstring(info.as_xml()).find('desc')
objs = desc.findall('objects/object')
chans = desc.findall('channels/channel')
assert info.channel_count() == len(chans) == 10 * len(objs), 'nombre de canaux incoherent'
assert int(desc.findtext('object_count')) == len(objs), 'object_count incorrect'
assert len(set(o.findtext('name') for o in objs)) == len(objs), 'noms non uniques'
for i, c in enumerate(chans):
o = objs[i // 10]
assert c.findtext('object') == o.findtext('name'), 'canal %d : objet incorrect' % i
assert c.findtext('type') == TYPES[i % 10], 'canal %d : type incorrect' % i
assert c.findtext('entity_type') == o.findtext('entity_type'), 'canal %d : entity_type incorrect' % i
sample, ts = inlet.pull_sample(timeout=2.0)
assert sample is not None and len(sample) == info.channel_count(), 'echantillon invalide'
print('OK :', len(objs), 'objets,', info.nominal_srate(), 'Hz')
Liste de contrôle :
- [ ]
type=Transforms,channel_format= float32,channel_count= N × 10 - [ ]
nominal_srateconforme à la cadence réelle - [ ]
source_idrenseigné - [ ]
descrempli avant la création de l'outlet - [ ]
coordinate_system,quaternion_order,position_unit,object_countprésents et corrects - [ ] Un
<object>par objet, avecidde 0 à N−1,nameunique etentity_type - [ ] N × 10 éléments
<channel>dans l'ordre exact des canaux - [ ] Quaternions unitaires, échelles positives, aucune valeur NaN
- [ ] Repère validé par un test visuel (section 6)
- [ ] Le script de validation ci-dessus passe sans erreur
14. Implémentation de référence
LslTransformSim est un exécutable Windows qui génère un flux conforme à cette spécification, avec des objets de types variés en mouvement. Il permet de comparer le flux de l'émetteur au flux attendu : lancer le script de validation sur chacun des deux et comparer le desc affiché par info.as_xml().
LslTransformSim.exe 5 à 15 objets au hasard
LslTransformSim.exe 8 8 objets
LslTransformSim.exe -n 12 -r 30 12 objets à 30 Hz
Options : --name, --type, --rate, --seed (rejouer une configuration), --help. Types d'entités générés : Enemy, Target, Civil, TeamMate, Neutral.