The LED Sensing Matrix

The LED Sensing Matrix is an interactive display that responds to light and shadow.

A standard LED is designed simply to emit light. But with some clever circuitry, we can configure it to detect light as well.

How does it work? Read on.

Multiplexing: Controlling Multiple LEDs

Controlling a single LED is simple.

  1. Connect the cathode to ground.
  2. Connect the anode to a positive voltage. In this case, we connect it to an output pin on our microcontroller (the Arduino Uno).

When the output is high (positive voltage), the light is on; and when the output is low (zero voltage), the light is off.

But what if we want to control many LEDs?

We could try connecting each anode to a separate output pin on the Uno. However, with a large number of LEDs, we would quickly run out of pins — not to mention electrical power. There is a better solution.

Multiplexing is a technique used to light many LEDs at the same time. The LEDs are arranged in a grid, as shown below. Each column shares a common anode, and each row shares a common cathode. To light an individual LED, say, LED 8, we would connect column 3 to power and row 2 to ground. In this example, we can control 25 individual LEDs using a mere 10 pins.

LED Multiplexing

Problem solved? Not quite. Say we want to light only LED 2 and LED 19. To turn on LED 2, we would connect column 2 to power and row 1 to ground. To turn on LED 19, we would connect column 4 to power and row 4 to ground. We would like for only LEDs 2 and 19 to be illuminated. But what we end with is a display like this:

LED Multiplexing Problem

LEDs 2 and 19 are illuminated — but so are the LEDs 4 and 17. The problem is that we can’t have a column connected to power in one row, and at the same time, have the column not connected to power in another. To resolve this issue, we connect each row to ground, one at a time, and light the appropriate columns. See the sequence of images below. If we do this fast enough, the human eye can’t notice the flicker of LEDs turning on and off, and it looks like we are lighting all the rows at the same time.

LED Multiplexing Demo

Lighting the Matrix

The Sensing Matrix display is made up of two 8 x 8 dot matrices (Sparkfun COM-00682), which are set up for multiplexing as shown in the diagram below. Each dot contains both a red and a green LED. By turning on only one LED, we can emit either red light or green light. By turning on both of them, we can display yellow light from the dot.

LED Dot Matrix Sparkfun COM-00682

Let us first consider only one of the 8 x 8 dot matrices. The 24 pins on the matrix outnumber the available outputs on the Uno, so we need some external device to control the display. For this purpose, we use three 8-bit shift registers (74HC595). Each register has eight digital outputs, similar to the ones on the Uno. To set the outputs high or low, we write an 8-bit word from the Uno to the register. Each bit in the word corresponds to an output on the register, and a 0 or 1 sets the output low or high, respectively. For more information on shift registers, look here.

LED Sensing Matrix with Shift Registers

The matrix datasheet tells us that each LED can handle a peak forward current of 30 mA. Go higher than this number and we risk damaging the circuit — go significantly lower and LEDs will appear dim. Given a 5 V power supply, we can restrict the current through a single LED to a safe 28 mA by putting a 180-ohm resistor at each anode. Since we are lighting the matrix one row at a time, each cathode must be capable of sinking enough current to light all 16 LEDs at once — a total of 448 mA.

It’s at this point that we run into a problem: the shift register can’t handle that much current. Each register output can only accommodate 20 mA — short of the 28 mA allotted for the each column and well below the 448 mA needed for each of the rows. Even if the shift register didn’t have this limitation, the Uno itself is only capable of sinking 400 mA. How do we get around this issue?

PNP transistors for sourcing current

use PNP transistors for sourcing current

Instead of using the register outputs to power the LEDs directly, we use them to control series of transistors. The cathode side is connected to ground through an NPN Darlington array (ULN2803), which is capable of sinking up to 500 mA per pin. On the anode side, each column is connected to a 5V power supply through a PNP transistor (2N3906), which can source up to 200 mA. (See image above.) Generally speaking, NPN transistors are better for sinking current and PNP transistors are better for sourcing current — which is why we have them at the cathode and the anode respectively. See the circuit diagram below. (Note: not pictured in this diagram are the 200-ohm resistors between the base of the PNP transistors and the shift register outputs.)

LED Sensing Matrix Circuit Diagram

An LED as a Light Sensor: Two Methods of Detection

What makes this project exciting is that we can use the LED matrix to detect light, not just emit it. The catch is that the LEDs can’t be illuminated during the sensing process. This means that sensing has to be done very quickly, so the human eye doesn’t notice that the LEDs have been switched off.

There are two methods of light detection with an LED, described below.

Cathode Sensing

This technique works by reverse biasing the LED. When the diode is in this state, it acts like a parallel plate capacitor. The diode’s depletion region serves as the insulator, and the anode and cathode act as the charged plates.

Reverse Biased Diode

When light hits the depletion region, it dislodges an electron, creating an electron-hole pair. The electric field then sweeps the hole to the p-side and the electron to n-side — creating a small reverse current from the cathode to anode. If more light hits the depletion region, a larger current is generated.

Electron Dislodged in Depletion Region

To measure this current, we switch the cathode connection from a 5V output to a digital input. We then measure the amount of time it takes for the input pin to go low. If there is a lot of light, then a large current will be generated and the input pin will fall low very rapidly. If there is only a little light, then a small current will be generated and the input pin will take a longer time to reach low.

Using LED to Detect Light

The advantage of this method is that it doesn’t require any additional hardware — with just two I/O pins on your microcontroller, you can both emit and detect light using an LED. The downside is that detecting light involves waiting for an input to drop low, which can take up to a few seconds in situations with little light.

Unfortunately, this means that cathode sensing and multiplexing are incompatible. For LED multiplexing to be effective, the LED can only be off for a few milliseconds, so the human eye doesn’t notice the flicker. If the LED needs to be off for multiple seconds to detect light, then there will be noticeable flickering.

Anode Sensing

Anode sensing requires more hardware (and is therefore more expensive) than cathode sensing, but measurements can be taken in microseconds rather than milliseconds or seconds.

The anode is connected to an analog input and the cathode is connected to ground. As in cathode sensing, when light hits the depletion region, it dislodges an electron to create an electron-hole pair. The electron is swept to the n-side and the hole is swept to the p-side. Charge quickly builds, and the voltage difference can be measured by the analog input at the anode. The more light that the LED detects, the larger the voltage will be.

Anode Sensing

With this method, we are able to measure a row of LEDs in mere microseconds — a large improvement from the previous method. Because of the speed of this technique, we will use anode sensing to detect light in the matrix.

The Uno only has six analog inputs, so for this application we use an external analog-to-digital converter (MCP3208). The MCP3208 is a 12-bit, 8-channel ADC that communicates with the Uno using the Serial Peripheral Interface. We connect each channel to an anode in the matrix, and read the values one at a time through pin 12 on the Uno.

Diagrams and Code

Final circuit diagrams and commented microcontroller code are shown below.

LED Sensing Matrix Final Circuit Diagram

In the final section of the posted video, a dot on the LED display is shown to “bounce” off of physical objects that are placed on the screen. The matrix senses that certain spots on the display are covered, and moves the dot accordingly. The commented microcontroller code is below.

#include 
#include 
 
#define DATA_ANODE 2
#define CLOCK_ANODE 3
#define LATCH_ANODE 4
 
#define DATA_CATHODE 6
#define CLOCK_CATHODE 7
#define LATCH_CATHODE 8
 
#define CS_0 9
#define CS_1 10
#define MOSI_ADC 11
#define MISO_ADC 12
#define CLOCK_ADC 13
 
int NUM_CALIB = 100;    // number of samples to be taken during calibration
int calibrate;          // number of samples taken so far during calibration
 
byte sensor[2][8];          // array to hold sensor readings (2 screens, 8 rows)
int ref[2][8][8];           // array to hold reference values
unsigned long avg[2][8][8]; // array to hold reference values, for calibration (2 screens, 8 rows, 8 cols)
 
byte screen_red[2][8];      // array representing all red anodes (8 anodes per screen, 2 screens)
byte screen_green[2][8];    // array representing all green anodes (8 anodes per screen, 2 screens)
 
int ball_x = 4;
int ball_y = 5;
 
int ball_dx = 1;
int ball_dy = -1;
 
unsigned long time;
 
void setup() {
 
  // begin serial communications at 57600 bps
  Serial.begin(57600);
 
  calibrate = 0;        // no calibration samples have been taken yet
 
  // Shift register pins to control anodes (columns)
  pinMode(LATCH_ANODE, OUTPUT);
  pinMode(CLOCK_ANODE, OUTPUT);
  pinMode(DATA_ANODE, OUTPUT);
 
  // Shift register pins to control cathodes (rows)
  pinMode(LATCH_CATHODE, OUTPUT);
  pinMode(CLOCK_CATHODE, OUTPUT);
  pinMode(DATA_CATHODE, OUTPUT);
 
}
 
void loop() {
 
  // if done calibrating...
  if (calibrate > NUM_CALIB*8*2) {
 
    measureRows();                    // take measurements at anode
 
    if (millis() - time > 100) {
      time = millis();
      updateScreen();
    }
 
    for (int i = 0; i < 8; i++) {
      lightRow(i);                    // turn on appropriate LEDs
    }
 
    resetAnodes();
 
  // if still calibrating...
  } else {
 
    clearScreen();                    // turn LEDs off
    measureRows();                    // take measurements at anode
 
    for (int i = 0; i < 8; i++) {
      lightRow(i);                    // turn on appropriate LEDs
    }
 
  }
 
}
 
void measureRows() {
 
  // setting PNP transistor bases to HIGH disconnects the anodes from the power supply
  digitalWrite(LATCH_ANODE, LOW);
  shiftOut(DATA_ANODE, CLOCK_ANODE, LSBFIRST, B11111111);    // set PNP bases HIGH 
  shiftOut(DATA_ANODE, CLOCK_ANODE, LSBFIRST, B11111111);    // set PNP bases HIGH
  digitalWrite(LATCH_ANODE, HIGH);
 
  for (int i = 0; i < 8; i++) {
 
    byte cat = B00000001 << i;   // corresponding bit in cathode register
 
    // set NPN transistor base HIGH to connect selected cathode to ground; leave
    // all other NPN bases LOW to disconnect remaining cathodes from GND
    digitalWrite(LATCH_CATHODE, LOW);
    shiftOut(DATA_CATHODE, CLOCK_CATHODE, MSBFIRST, cat);    // set one NPN base HIGH, others LOW
    shiftOut(DATA_CATHODE, CLOCK_CATHODE, MSBFIRST, cat);    // set one NPN base HIGH, others LOW
    digitalWrite(LATCH_CATHODE, HIGH);
 
    // current state:
    // - all anodes disconnected
    // - cathodes for selected row connected to GND, all other cathodes disconnected
 
    measureRow(i, 0);    // measure selected row on screen 0
    measureRow(i, 1);    // measure selected row on screen 1
  }
 
}
 
void measureRow(int row, int screen) {
 
  int cs;    // chip select
 
  // choose screen to read from
  if (screen == 0)
    cs = CS_0;
  else if (screen == 1)
    cs = CS_1;
 
  // set up ADC (MCP3208)
  AH_MCP320x ADC_SPI(cs);
 
  delayMicroseconds(50);    // pause to let charge build
 
  int rowValues[8];
  ADC_SPI.readALL(rowValues, 8);   // get voltage reading from all 8 columns
 
  // if we are still calibrating the device...
  if (calibrate < NUM_CALIB*8*2) {
    calibrate++;      // add to calibration sample count
 
    // add raw values into array, values to be averaged later
    for (int j = 0; j < 8; j++) {
      avg[screen][row][j] += rowValues[j];
    }
  }
 
  // if we have just finished calibrating the device...
  if (calibrate == NUM_CALIB*8*2) {
 
    calibrate++;    // add to calibration count so we don't repeat this code later
    for (int k = 0; k < 2; k++) {
      for (int i = 0; i < 8; i++) {
        for (int j = 0; j < 8; j++) {
          avg[k][i][j] = avg[k][i][j] / NUM_CALIB;     // average values
          ref[k][i][j] = (int) (avg[k][i][j] * 0.75);  // compute reference values
        }
        sensor[k][i] = B11111111;                          // set up array to hold sensor data
      }
    }
 
  }
 
  // if the device has already been calibrated...  
  if (calibrate > NUM_CALIB*8*2) {
 
    // compare measured value to reference value, and record in sensor array
    for (int j = 0; j < 8; j++) {
 
      if (rowValues[j] < ref[screen][row][j])
        sensor[screen][row] &= ~(B10000000 >> j);  // measured value is lower, light not detected
      else
        sensor[screen][row] |= B10000000 >> j;     // measured value is higher, light detected
 
    }
  }
 
}
 
void updateScreen() {
 
  byte ball_byte = B10000000 >> (ball_x % 8);
  byte ball_screen = (ball_x - (ball_x % 8)) / 8;
 
  // erase ball from screen
  screen_red[ball_screen][ball_y] |= ball_byte;
  screen_green[ball_screen][ball_y] |= ball_byte;
 
  int ball_screen_dx;
  int ball_byte_dx;
 
  if (ball_x + ball_dx == 8 && ball_x == 7) {
    ball_screen_dx = ball_screen + 1;
    ball_byte_dx = B10000000;
  } else if (ball_x + ball_dx == 7 && ball_x == 8) {
    ball_screen_dx = ball_screen - 1;
    ball_byte_dx = B00000001;
  } else {
    ball_screen_dx = ball_screen;
    if (ball_dx > 0)
      ball_byte_dx = ball_byte >> 1;
    else
      ball_byte_dx = ball_byte << 1;
  }
 
  // x collision
  if (((~sensor[ball_screen_dx][ball_y] & ball_byte_dx) != 0) || (ball_x + ball_dx > 15 || ball_x + ball_dx < 0))
    ball_dx *= -1;
 
  // y collision
  if (((~sensor[ball_screen][ball_y + ball_dy] & ball_byte) != 0) || (ball_y + ball_dy > 7 || ball_y + ball_dy < 0))
    ball_dy *= -1;
 
  // update ball location
  ball_x += ball_dx;
  ball_y += ball_dy;
 
  ball_screen = (ball_x - (ball_x % 8)) / 8;
 
  // add ball to screen
  ball_byte = B10000000 >> (ball_x % 8);
  screen_red[ball_screen][ball_y] &= ~ball_byte;
  screen_green[ball_screen][ball_y] &= ~ball_byte;
 
}
 
// Turn on appropriate LEDs for the given row
void lightRow(int row) {
byte cat = B00000001 << row;   // corresponding bit in cathode register
 
  // set NPN transistor base HIGH to connect selected cathode to ground; leave
  // all other NPN bases LOW to disconnect remaining cathodes from GND
  digitalWrite(LATCH_CATHODE, LOW);
  shiftOut(DATA_CATHODE, CLOCK_CATHODE, MSBFIRST, cat);    // set one NPN base HIGH, others LOW
  shiftOut(DATA_CATHODE, CLOCK_CATHODE, MSBFIRST, cat);    // set one NPN base HIGH, others LOW
 
  // current state:
  // - all anodes disconnected
  // - cathodes for selected row connected to GND, all other cathodes disconnected
 
  // setting PNP transistor bases to LOW connects the anodes to the power supply
  // this code sets appropriate bases LOW according to screen_red and screen_green arrays
  digitalWrite(LATCH_ANODE, LOW);
  shiftOut(DATA_ANODE, CLOCK_ANODE, LSBFIRST, screen_red[1][row]);      
  shiftOut(DATA_ANODE, CLOCK_ANODE, LSBFIRST, screen_red[0][row]);
  shiftOut(DATA_ANODE, CLOCK_ANODE, LSBFIRST, screen_green[1][row]);      
  shiftOut(DATA_ANODE, CLOCK_ANODE, LSBFIRST, screen_green[0][row]);
 
  digitalWrite(LATCH_CATHODE, HIGH);
  digitalWrite(LATCH_ANODE, HIGH);
 
  // current state:
  // - some anodes connected to power, the rest are disconnected
  // - cathodes for selected row connected to GND, all other cathodes disconnected
 
  delayMicroseconds(500);
 
}
 
void resetAnodes() {
// setting PNP transistor bases to HIGH disconnects the anodes from the power supply
  digitalWrite(LATCH_ANODE, LOW);
  shiftOut(DATA_ANODE, CLOCK_ANODE, LSBFIRST, B11111111);    // set PNP bases HIGH 
  shiftOut(DATA_ANODE, CLOCK_ANODE, LSBFIRST, B11111111);    // set PNP bases HIGH
  digitalWrite(LATCH_ANODE, HIGH);
}
 
// wipe screen (set LEDs off)
void clearScreen() {
  for (int k = 0; k < 2; k++) {
    for (int i = 0; i < 8; i ++) {
      screen_red[k][i] = B11111111;
      screen_green[k][i] = B11111111;
    }
  }
}	

Questions? Comments? Feel free to contact me here.