Laser Distance VL53L0X

Distance measurement is a crucial aspect in various applications such as robotics, automation, and proximity sensing. The VL53L0X sensor is a state-of-the-art, time-of-flight (ToF) laser-ranging module, providing accurate distance measurement. It works by emitting a very short infrared laser pulse and then measuring the time taken for the light to be reflected back to the sensor to calculate the distance to the target object.

The VL53L0X is capable of measuring distances up to 2 meters with a high degree of accuracy and operates effectively in a variety of conditions. Applications include obstacle avoidance in robotics, user detection for power-saving applications, and other object location detection applications.

VL53L0X Distance Measurement Module

The VL53L0X module integrates an SPAD (Single Photon Avalanche Diodes) array, a 940 nm VCSEL (Vertical Cavity Surface-Emitting Laser) emitter with infrared light invisible to the human eye, and an embedded microcontroller that processes the ToF data. It has high precision, low power consumption, and immunity to ambient light with internal physical infrared filters.

This module is interfaced with microcontrollers (e.g. Arduino or ESP32) with an I2C connection. The VL53L0X has a continuous measurement mode to reduce software overhead for high-speed applications (400 kHz). The following is code to read 20 distance (mm) values over 10 seconds. The sensor used in this demonstration requires a 3.3V power source.

import time
from vl53l0x import VL53L0X
from machine import Pin,SoftI2C

i2c = SoftI2C(scl=Pin(22),sda=Pin(21))
dist = VL53L0X(i2c)

for _ in range(20):
   print(f'Distance: {dist.read()} mm')
   time.sleep(0.5)

The vl53l0x.py script is required to connect and access the VL53L0X distance readings.

from micropython import const
import ustruct
import utime
from machine import I2C

_IO_TIMEOUT = 1000
_SYSRANGE_START = const(0x00)
_EXTSUP_HV = const(0x89)
_MSRC_CONFIG = const(0x60)
_FINAL_RATE_RTN_LIMIT = const(0x44)
_SYSTEM_SEQUENCE = const(0x01)
_SPAD_REF_START = const(0x4f)
_SPAD_ENABLES = const(0xb0)
_REF_EN_START_SELECT = const(0xb6)
_SPAD_NUM_REQUESTED = const(0x4e)
_INTERRUPT_GPIO = const(0x0a)
_INTERRUPT_CLEAR = const(0x0b)
_GPIO_MUX_ACTIVE_HIGH = const(0x84)
_RESULT_INTERRUPT_STATUS = const(0x13)
_RESULT_RANGE_STATUS = const(0x14)
_OSC_CALIBRATE = const(0xf8)
_MEASURE_PERIOD = const(0x04)

class TimeoutError(RuntimeError):
    pass

class VL53L0X:
    def __init__(self, i2c, address=0x29):
        if isinstance(i2c, str):           # Non-pyb targets may use other than X or Y
            self.i2c = I2C(i2c)
        elif isinstance(i2c, int):         # WiPY targets
            self.i2c = I2C(i2c)
        elif hasattr(i2c, 'readfrom'):     # Soft or hard I2C instance.
            self.i2c = i2c
        else:
            raise ValueError("Invalid I2C instance")

        self.address = address
        self.init()
        self._started = False

    def _registers(self, register, values=None, struct='B'):
        if values is None:
            size = ustruct.calcsize(struct)
            data = self.i2c.readfrom_mem(self.address, register, size)
            values = ustruct.unpack(struct, data)
            return values
        data = ustruct.pack(struct, *values)
        self.i2c.writeto_mem(self.address, register, data)

    def _register(self, register, value=None, struct='B'):
        if value is None:
            return self._registers(register, struct=struct)[0]
        self._registers(register, (value,), struct=struct)

    def _flag(self, register=0x00, bit=0, value=None):
        data = self._register(register)
        mask = 1 << bit
        if value is None:
            return bool(data & mask)
        elif value:
            data |= mask
        else:
            data &= ~mask
        self._register(register, data)

    def _config(self, *config):
        for register, value in config:
            self._register(register, value)

    def init(self, power2v8=True):
        self._flag(_EXTSUP_HV, 0, power2v8)

        # I2C standard mode
        self._config(
            (0x88, 0x00),

            (0x80, 0x01),
            (0xff, 0x01),
            (0x00, 0x00),
        )
        self._stop_variable = self._register(0x91)
        self._config(
            (0x00, 0x01),
            (0xff, 0x00),
            (0x80, 0x00),
        )

        # disable signal_rate_msrc and signal_rate_pre_range limit checks
        self._flag(_MSRC_CONFIG, 1, True)
        self._flag(_MSRC_CONFIG, 4, True)

        # rate_limit = 0.25
        self._register(_FINAL_RATE_RTN_LIMIT, int(0.25 * (1 << 7)),
                       struct='>H')

        self._register(_SYSTEM_SEQUENCE, 0xff)

        spad_count, is_aperture = self._spad_info()
        spad_map = bytearray(self._registers(_SPAD_ENABLES, struct='6B'))

        # set reference spads
        self._config(
            (0xff, 0x01),
            (_SPAD_REF_START, 0x00),
            (_SPAD_NUM_REQUESTED, 0x2c),
            (0xff, 0x00),
            (_REF_EN_START_SELECT, 0xb4),
        )

        spads_enabled = 0
        for i in range(48):
            if i < 12 and is_aperture or spads_enabled >= spad_count:
                spad_map[i // 8] &= ~(1 << (i >> 2))
            elif spad_map[i // 8] & (1 << (i >> 2)):
                spads_enabled += 1

        self._registers(_SPAD_ENABLES, spad_map, struct='6B')

        self._config(
            (0xff, 0x01),
            (0x00, 0x00),

            (0xff, 0x00),
            (0x09, 0x00),
            (0x10, 0x00),
            (0x11, 0x00),

            (0x24, 0x01),
            (0x25, 0xFF),
            (0x75, 0x00),

            (0xFF, 0x01),
            (0x4E, 0x2C),
            (0x48, 0x00),
            (0x30, 0x20),

            (0xFF, 0x00),
            (0x30, 0x09),
            (0x54, 0x00),
            (0x31, 0x04),
            (0x32, 0x03),
            (0x40, 0x83),
            (0x46, 0x25),
            (0x60, 0x00),
            (0x27, 0x00),
            (0x50, 0x06),
            (0x51, 0x00),
            (0x52, 0x96),
            (0x56, 0x08),
            (0x57, 0x30),
            (0x61, 0x00),
            (0x62, 0x00),
            (0x64, 0x00),
            (0x65, 0x00),
            (0x66, 0xA0),

            (0xFF, 0x01),
            (0x22, 0x32),
            (0x47, 0x14),
            (0x49, 0xFF),
            (0x4A, 0x00),

            (0xFF, 0x00),
            (0x7A, 0x0A),
            (0x7B, 0x00),
            (0x78, 0x21),

            (0xFF, 0x01),
            (0x23, 0x34),
            (0x42, 0x00),
            (0x44, 0xFF),
            (0x45, 0x26),
            (0x46, 0x05),
            (0x40, 0x40),
            (0x0E, 0x06),
            (0x20, 0x1A),
            (0x43, 0x40),

            (0xFF, 0x00),
            (0x34, 0x03),
            (0x35, 0x44),

            (0xFF, 0x01),
            (0x31, 0x04),
            (0x4B, 0x09),
            (0x4C, 0x05),
            (0x4D, 0x04),

            (0xFF, 0x00),
            (0x44, 0x00),
            (0x45, 0x20),
            (0x47, 0x08),
            (0x48, 0x28),
            (0x67, 0x00),
            (0x70, 0x04),
            (0x71, 0x01),
            (0x72, 0xFE),
            (0x76, 0x00),
            (0x77, 0x00),

            (0xFF, 0x01),
            (0x0D, 0x01),

            (0xFF, 0x00),
            (0x80, 0x01),
            (0x01, 0xF8),

            (0xFF, 0x01),
            (0x8E, 0x01),
            (0x00, 0x01),
            (0xFF, 0x00),
            (0x80, 0x00),
        )

        self._register(_INTERRUPT_GPIO, 0x04)
        self._flag(_GPIO_MUX_ACTIVE_HIGH, 4, False)
        self._register(_INTERRUPT_CLEAR, 0x01)

        # XXX Need to implement this.
        #budget = self._timing_budget()
        #self._register(_SYSTEM_SEQUENCE, 0xe8)
        #self._timing_budget(budget)

        self._register(_SYSTEM_SEQUENCE, 0x01)
        self._calibrate(0x40)
        self._register(_SYSTEM_SEQUENCE, 0x02)
        self._calibrate(0x00)

        self._register(_SYSTEM_SEQUENCE, 0xe8)

    def _spad_info(self):
        self._config(
            (0x80, 0x01),
            (0xff, 0x01),
            (0x00, 0x00),

            (0xff, 0x06),
        )
        self._flag(0x83, 3, True)
        self._config(
            (0xff, 0x07),
            (0x81, 0x01),

            (0x80, 0x01),

            (0x94, 0x6b),
            (0x83, 0x00),
        )
        for timeout in range(_IO_TIMEOUT):
            if self._register(0x83):
                break
            utime.sleep_ms(1)
        else:
            raise TimeoutError()
        self._config(
            (0x83, 0x01),
        )
        value = self._register(0x92)
        self._config(
            (0x81, 0x00),
            (0xff, 0x06),
        )
        self._flag(0x83, 3, False)
        self._config(
            (0xff, 0x01),
            (0x00, 0x01),

            (0xff, 0x00),
            (0x80, 0x00),
        )
        count = value & 0x7f
        is_aperture = bool(value & 0b10000000)
        return count, is_aperture

    def _calibrate(self, vhv_init_byte):
        self._register(_SYSRANGE_START, 0x01 | vhv_init_byte)
        for timeout in range(_IO_TIMEOUT):
            if self._register(_RESULT_INTERRUPT_STATUS) & 0x07:
                break
            utime.sleep_ms(1)
        else:
            raise TimeoutError()
        self._register(_INTERRUPT_CLEAR, 0x01)
        self._register(_SYSRANGE_START, 0x00)

    def start(self, period=0):
        self._config(
          (0x80, 0x01),
          (0xFF, 0x01),
          (0x00, 0x00),
          (0x91, self._stop_variable),
          (0x00, 0x01),
          (0xFF, 0x00),
          (0x80, 0x00),
        )
        if period:
            oscilator = self._register(_OSC_CALIBRATE, struct='>H')
            if oscilator:
                period *= oscilator
            self._register(_MEASURE_PERIOD, period, struct='>H')
            self._register(_SYSRANGE_START, 0x04)
        else:
            self._register(_SYSRANGE_START, 0x02)
        self._started = True

    def stop(self):
        self._register(_SYSRANGE_START, 0x01)
        self._config(
          (0xFF, 0x01),
          (0x00, 0x00),
          (0x91, self._stop_variable),
          (0x00, 0x01),
          (0xFF, 0x00),
        )
        self._started = False

    def read(self):
        if not self._started:
            self._config(
              (0x80, 0x01),
              (0xFF, 0x01),
              (0x00, 0x00),
              (0x91, self._stop_variable),
              (0x00, 0x01),
              (0xFF, 0x00),
              (0x80, 0x00),
              (_SYSRANGE_START, 0x01),
            )
            for timeout in range(_IO_TIMEOUT):
                if not self._register(_SYSRANGE_START) & 0x01:
                    break
                utime.sleep_ms(1)
            else:
                raise TimeoutError()
        for timeout in range(_IO_TIMEOUT):
            if self._register(_RESULT_INTERRUPT_STATUS) & 0x07:
                break
            utime.sleep_ms(1)
        else:
            raise TimeoutError()
        value = self._register(_RESULT_RANGE_STATUS + 10, struct='>H')
        self._register(_INTERRUPT_CLEAR, 0x01)
        return value

✅ Activity: Distance Measurement with VL53L0X

Here are a few examples of applications where a distance sensor like the VL53L0X might be used:

  • Robotics: The sensor can be used for obstacle detection and navigation. For instance, a robot can use it to avoid collisions or follow a predefined path.
  • Smart Devices: In devices like smartphones or tablets, the VL53L0X can be used for gesture recognition or to enhance autofocus in cameras.
  • Industrial Automation: The sensor can be employed to monitor the position of objects on a conveyor belt or to control robotic arms in assembly lines.
  • Research and Development: It can be used in scientific studies requiring precise distance measurements, like in physics experiments or environmental monitoring.

Experiment with the VL53L0X module to record measurements at known distances. Analyze how the sensor performs with varying distances. Discuss the implications of the findings in terms of sensor accuracy with the potential applications.

Collect Data

Use the sample data or collect new data with the following Python script. The script takes 100 samples every 5 seconds. Use a yard stick, meter stick, or other measurement device to position an object at the requested distance from the sensor. One way to do this is to place the sensor flat on the ground and place the meter stick vertically. Use a flat object such as a plate or piece of cardboard that can be positioned at the correct location along the meter stick. When the new prompt is given, there is a 5 second delay to enable the flat object to be moved to that position. Increase to 10 seconds delay with time.sleep(5) to time.sleep(10) if more time is needed. The data file dist_data.csv is stored on the ESP32 device.

import time
from vl53l0x import VL53L0X
from machine import Pin,SoftI2C

i2c = SoftI2C(scl=Pin(22),sda=Pin(21))
dist = VL53L0X(i2c)

fid = open('dist_data.csv','w')
fid.write('Time,Dist,Measured\n')
ts = time.time()

# calibration distances (inches)
for d in [35,20,10,30,15,5,25,0,35]:
   print(f'------Set distance to {d} inches---------')
   time.sleep(5)
   print('Reading 100 samples')
   for _ in range(100):
      fid.write(f'{time.time()-ts},{d},{dist.read()}\n')
      time.sleep(0.01)
fid.close()
print('done')

Compare the results of this test to the HC-SR04 Ultrasonic Distance Sensor. What are the advantages and disadvantages of using light versus sound to measure distance?

Solution

Import the data and convert distance from inches to millimeters. Remove the Time column to help with plotting the data.

import pandas as pd
import matplotlib.pyplot as plt

data = pd.read_csv('http://apmonitor.com/dde/uploads/Main/dist_data.csv')
# convert inches to mm
data['Dist'] = data['Dist'].values * 25.4
# remove Time column
del data['Time']

data.plot(figsize=(6,3.5))

At about 1000mm, the sensor has several points that do not detect an object and the upper limit is reached. The VL53L0X has a stated range up to 2000mm , but this test indicates a maximum range up to about 1000mm. Remove data above 1200mm to remove the bad data points.

# remove data above 1200
data = data[data['Measured']<=1200]
data.plot(figsize=(6,3.5))

Next, produce a scatter plot that shows the measured versus recorded distance. The lower limit for the distance sensor is about 40mm. The sensor also decreases in accuracy as the distance increases as shown with the additional vertical scatter.

data.plot(x='Dist',y='Measured',kind='scatter',figsize=(6,3.5))
upper = data['Dist'].max()
plt.plot([0,upper],[0,upper],'r-')

The results show that the laser distance sensor is most accurate below 800mm and has a lower limit of 40mm. The sensor can be used in applications that fall within this range and the sample frequency may be high enough so that multiple samples can be used to reduce distance uncertainty.