Aller au contenu

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

  1. 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.
  2. Il y a exactement N × 10 éléments <channel>, et chaque couple (object, type) apparaît une seule fois.
  3. L'ordre des objets dans <objects> est le même que l'ordre des blocs de 10 canaux dans <channels>.
  4. Les champs sont en texte brut UTF-8 (ASCII recommandé), sans espace en début ni en fin de valeur.
  5. 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_sample par pas de temps, contenant les N objets. Jamais d'échantillon partiel.
  • Cadence : constante de préférence (référence : 60 Hz). nominal_srate doit 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 champs object et type de channels/channel. Le champ label n'est pas interprété.
  • Un objet 3D est créé dynamiquement pour chaque nom d'objet distinct.
  • La comparaison du champ type ignore 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és dyn_object_i et 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_srate conforme à la cadence réelle
  • [ ] source_id renseigné
  • [ ] desc rempli avant la création de l'outlet
  • [ ] coordinate_system, quaternion_order, position_unit, object_count présents et corrects
  • [ ] Un <object> par objet, avec id de 0 à N−1, name unique et entity_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.

Télécharger le programme LSLTransformSim.exe