Visualizing the bird orientation with Three.js

For this week, I decided to explore the Three.js library and use it to expose my bird in a 3D interface for the browser. Although I'm quite experienced with graphics (being my background), I have not done much WebGL and was really positively surprised by Three.js and its extensive examples

I used a mix of the sky example with mesh loading and found a free (as in public domain free) bird model for it. Given that most examples were including dat.gui, I also considered it. I decided to use it for exposing the current parameters with simplicity although it is not sufficient for a good control of the bird which will require probably some more focused interface including keyboard control.

The demo is there but requires the server nodejs script to be running locally to show anything dynamic.

Client code tricks

dat.gui was quite intuitive (very similar to NanoGUI or Ant TweakBar) to use except for a quite ugly way to update the interface manually when updating the variables from the code.

As for the websockets, this was also easy, with the exception of their interaction with the rendering of Three.js. Because my model is quite heavy and the flow of data from the chip was quite fast, I found that throttling it by using a timeout between the rendering update and the next data reading from the websocket was quite useful. The trick is that the websocket does a message passing where the server only answers with the most recent state when asked for something and not streaming continuously and thus the client can decide to update its view at its own pace instead of having to go as fast as possible and killing the user interface. This means sending the request for an update only after some delay with setTimeout.

Server code tricks

On the server side, I had to go through a lot of problems, all mostly due to the difference in node version between the computers I used. For some bad reason, node.js doesn't default to using strict mode which means that no error is triggered when accessing undefined elements. This means that one can write meaningless code, but no error is triggered in response, and so it becomes hard to debug why nothing happens as expected. The solution (in recent node version from 0.10) is to place "use strict"; at the top of the script (or function where we want the scope to be in strict mode that does more compile-time verifications).

Beyond strict mode solving most typos / simple errors, I realized that the newer versions of Node SerialPort and websocket have a few modifications that may be needed to work well everywhere:

I adapted the python script from the accelerator of the input device week. This meant converting the direct read calls into a streaming version that would keep a buffers on the side (for the 1234 frame bytes and the next 6 bytes of information) and trigger the control flow when all buffers were correctly filled.

#!/usr/bin/env node

"use strict";

// dependencies
var serialport = require("serialport");
var SerialPort = serialport.SerialPort || serialport;

if((process.argv[2] || '').match(/-h|--help/)){
  console.log('Usage: server.js <port=/dev/ttyUSB0> <port=1234> <bitrate=9600>');
  process.exit(0);
}

var client_address = '127.0.0.1'
var serial_port = process.argv[2] || "/dev/ttyUSB0"
var server_port = process.argv[3] || '1234'
var baud = process.argv[4] || 9600
var expRate = 0.05;

// create port
var sp = new SerialPort(serial_port, {baudRate: baud, autoOpen: false});

//
// look for framing and then update field value
//
var value = { orientX: 0, orientY: 0, orientZ: 0 };
var frame = [0, 0, 0, 0];
var queue  = [];
var merge16bits = function(byte0, byte1){
  var b = byte0 + 0xFF * byte1;
  if(b & 0x8000)
    b = -(0x10000 - b);
  return b;
};
var processByte = function(data){
  var inFrame = frame[0] == 1 && frame[1] == 2 && frame[2] == 3 && frame[3] == 4;
  if(!inFrame){
    // read frame data
    frame.shift();
    frame.push(data);
  } else {
    // read accel data
    queue.push(data);

    if(queue.length == 6){
      var rx = merge16bits(queue[0], queue[1]);
      var ry = merge16bits(queue[2], queue[3]);
      var rz = merge16bits(queue[4], queue[5]);
      value.orientX = value.orientX * (1 - expRate) + expRate * rx;
      value.orientY = value.orientY * (1 - expRate) + expRate * ry;
      value.orientZ = value.orientZ * (1 - expRate) + expRate * rz;
      // clear data
      queue = [];
    }
  }
};
sp.open(function(error) {
  if (error) {
    console.log('can not open '+serial_port);
    console.log(error);
    // defaults to outputting random sinewaves
    value.connected = false;
  } else {
    console.log('opened ' + serial_port);
    value.connected = true;
  }
});

// by default, stream random data
var t = 0;
var freq = Math.PI / 1024 * 10;
var namp = 1/32;
var randomInt = setInterval(function(){
  value.orientX = Math.round(1023 * (Math.sin(t * freq) + (Math.random() - 0.5) * namp));
  value.orientY = Math.round(1023 * (Math.sin(t * freq / 2) + (Math.random() - 0.5) * namp));
  value.orientZ = Math.round(1023 * (Math.sin(t * freq / 4) + (Math.random() - 0.5) * namp));
  t += 1;
}, 100);

// real data from accelerometer
sp.on('data', function(data) {
  if(randomInt !== undefined){
    clearInterval(randomInt);
    randomInt = undefined;
    console.log('somehow connected to ' + serial_port);
    value.connected = true;
  }
  for(var i = 0; i < data.length; ++i){
    processByte(data[i]);
  }
});

//
// wait for socket request and then send field value
//
console.log("listening for connections from "+client_address+" on "+server_port);
var ws = require('ws');
var wss = new ws.Server({port:server_port});
wss.on('connection', function(ws) {
  var addr = ws._socket.remoteAddress;
  var ipv4 = client_address;
  var ipv6 = '::ffff:' + client_address;
  if (addr != ipv4 && addr != ipv6) {
    console.log("error: client address doesn't match");
    console.log('- should be: ' + client_address);
    console.log('- really is: ' + ws._socket.remoteAddress);
    return;
  }
  console.log("connected to "+client_address+" on port "+server_port);
  var connection = false;
  ws.on('message', function(data) {
    // connected attribute
    if(connection)
      delete value.connected;
    else
      connection = 'connected' in value;
    // send data as json
    ws.send(JSON.stringify(value));
  });
});

I also made it such that if there is no input serial available, then random sinewaves are generated to simulate some data. The server code is there.

Finally, it works! But there's a clear need of data calibration to match the board orientation with the bird pose.

W13 - Update

I digged deeper and found that the shakiness was not a hardware issue but a pure software bug. The code above doesn't clear the frame buffer so that all the next 1234 bits are pulled into the acceleration data. In fact, it's quite suprising that the code above looked like it was doing something correct given that it was mostly showing random (although correlated) noise.

After clearing the queue with queue = [];, the updated code also clears the frame buffer with frame = [0, 0, 0, 0];. The result is a much cleaner acceleration data! Hooray!