Colour-sorting machine: making a TCS3200 tell red from orange
- Build Logs
- 30 Jul, 2026
A colour sorter is the rare project that looks like a toy and teaches something genuinely useful: that a sensor almost never gives you the quantity you actually want. The TCS3200 does not report "red". It reports three frequencies, and turning those into "red" is the entire job.
Most builds of this skip the calibration and then spend an evening wondering why the machine thinks a red bead is orange. That step is not optional, so we will do it properly.
What you will need
| Part | Qty | Approx. |
|---|---|---|
| TCS3200 Colour Recognition Sensor | 1 | ₹405 |
| Arduino Uno R3 (compatible) | 1 | ₹418 |
| SG90 Micro Servo (comes with an HC-SR04) | 1 | ₹183 |
| 16×2 LCD with I2C | 1 | ₹149 |
Roughly ₹1,155. Plus cardboard for the chute and something to sort — coloured beads, M&Ms, or Lego bricks.
How the sensor actually works
Under the lens is an 8×8 grid of photodiodes: 16 filtered red, 16 green, 16 blue, and 16 unfiltered. You choose which set to read by driving two pins, S2 and S3. The chip then outputs a square wave whose frequency is proportional to the light intensity on that set.
So you do not read a value. You measure a period, with pulseIn(), three times — once per filter.
| S2 | S3 | Reads |
|---|---|---|
| LOW | LOW | Red |
| LOW | HIGH | Blue |
| HIGH | HIGH | Green |
| HIGH | LOW | Clear (no filter) |
Note the counter-intuitive part: a brighter colour gives a SHORTER pulse, because brighter means higher frequency. So a red bead produces a small number on the red channel. Getting this backwards is the first bug everyone hits.
S0 and S1 set a frequency divider. Use 20% (S0 HIGH, S1 LOW) — the full-speed output is too fast for an Uno to time reliably, and the slowest setting makes each reading take ages.
Wiring
| From | To |
|---|---|
| TCS3200 S0 / S1 | Uno D4 / D5 |
| TCS3200 S2 / S3 | Uno D6 / D7 |
| TCS3200 OUT | Uno D8 |
| TCS3200 LED | Uno D9 (or straight to 5V) |
| TCS3200 VCC / GND | Uno 5V / GND |
| Servo signal | Uno D10 |
| LCD SDA / SCL | Uno A4 / A5 |
Mount the sensor 10–15 mm above the object and shroud it. The four white LEDs on the board exist to give you a known, constant light source; ambient light landing on the photodiodes is pure noise. A cardboard tube painted matt black on the inside is worth more to this project than any amount of code.
Calibration: the step that makes it work
Raw pulse widths are meaningless on their own — they depend on your LEDs, your sensor height, the ambient light and the surface finish. What you need is each channel scaled between the darkest and brightest thing it will ever see.
#define S0 4
#define S1 5
#define S2 6
#define S3 7
#define OUT 8
int rMin=1023, gMin=1023, bMin=1023; // brightest = shortest pulse
int rMax=0, gMax=0, bMax=0; // darkest = longest pulse
int readChannel(bool s2, bool s3) {
digitalWrite(S2, s2);
digitalWrite(S3, s3);
delay(20); // let the filter switch settle
return pulseIn(OUT, LOW);
}
int readRed() { return readChannel(LOW, LOW ); }
int readBlue() { return readChannel(LOW, HIGH); }
int readGreen() { return readChannel(HIGH, HIGH); }
void calibrate() {
// Hold a WHITE object under the sensor for 5 seconds, then a BLACK one.
// White gives the shortest pulses, black the longest.
unsigned long end = millis() + 10000;
while (millis() < end) {
int r = readRed(), g = readGreen(), b = readBlue();
rMin = min(rMin, r); rMax = max(rMax, r);
gMin = min(gMin, g); gMax = max(gMax, g);
bMin = min(bMin, b); bMax = max(bMax, b);
}
}
// Map each channel to 0..255 using its OWN measured range.
void readRGB(int &r, int &g, int &b) {
r = constrain(map(readRed(), rMin, rMax, 255, 0), 0, 255);
g = constrain(map(readGreen(), gMin, gMax, 255, 0), 0, 255);
b = constrain(map(readBlue(), bMin, bMax, 255, 0), 0, 255);
}
Note the inverted map() — rMin (shortest pulse, brightest) maps to 255. That inversion is the whole trick.
Each channel gets its own min and max. The red, green and blue photodiodes do not have equal sensitivity, and the white LEDs are not spectrally flat, so a shared scale leaves a permanent colour cast — which is exactly why an uncalibrated sorter reads everything as slightly blue.
Classifying, and why red vs orange is hard
The obvious approach is a chain of if statements on R, G and B. It works badly, because red (255,0,0) and orange (255,120,0) differ only in the green channel — and your thresholds will be wrong for the next bead.
The better method is nearest-neighbour against samples you actually measured:
struct Sample { const char* name; int r, g, b; int servoAngle; };
// Fill these in by running readRGB() on each real object and
// writing down what you get. Do NOT copy values from a tutorial.
Sample known[] = {
{"Red", 212, 38, 44, 30},
{"Green", 52, 188, 76, 70},
{"Blue", 40, 72, 196, 110},
{"Yellow", 226, 208, 52, 150}
};
const int KNOWN_COUNT = 4;
const long MAX_DISTANCE_SQ = 90L * 90L; // beyond this, call it unknown
int classify(int r, int g, int b) {
long best = MAX_DISTANCE_SQ;
int bestIndex = -1;
for (int i = 0; i < KNOWN_COUNT; i++) {
long dr = r - known[i].r, dg = g - known[i].g, db = b - known[i].b;
long d = dr*dr + dg*dg + db*db; // squared distance, no sqrt needed
if (d < best) { best = d; bestIndex = i; }
}
return bestIndex; // -1 means "not one of mine"
}
This is a real classifier, and it degrades gracefully: an object that matches nothing returns −1 rather than being forced into the closest bucket. The MAX_DISTANCE_SQ gate is what makes the machine say "I do not know" instead of confidently dropping an unknown bead into the red bin.
Averaging, and how slow this really is
Each readChannel() costs a 20 ms settle plus a pulseIn(). Three channels is roughly 70–100 ms per reading, and you want several readings averaged. Budget about half a second per object.
void readAveraged(int &r, int &g, int &b, int n = 5) {
long rs=0, gs=0, bs=0;
for (int i = 0; i < n; i++) {
int ri, gi, bi;
readRGB(ri, gi, bi);
rs += ri; gs += gi; bs += bi;
}
r = rs/n; g = gs/n; b = bs/n;
}
That is fine for a demonstration and hopeless for a production line. Worth knowing before you promise anyone a fast machine.
Things that go wrong
Every colour reads the same. The LEDs are off. Tie the LED pin high, or drive D9 HIGH in setup().
Readings drift through the day. Ambient light is reaching the sensor. Shroud it. This is the single biggest cause of a sorter that worked yesterday and does not today.
pulseIn() returns 0. S0/S1 are set to full speed and the Uno cannot time it. Use the 20% divider.
Red and orange are confused. Expected with threshold logic. Use the nearest-neighbour approach, and re-sample your actual objects.
Shiny objects read as white. A specular highlight bounces the LEDs straight back. Matt objects sort far more reliably; this is a genuine limitation, not a bug.
Servo jitters while the sensor reads. The Servo library and pulseIn() both care about timing. Detach the servo while measuring, then re-attach to move.
Where to take it next
Add a second servo for a feed gate and you have a machine that runs unattended. Swap the LCD for the NodeMCU web dashboard and it can report a running tally of what it has sorted — which is a genuinely nice science-fair result, because the data is the output rather than the motion.
Ask our engineers if your calibration will not settle — nine times in ten it is stray light, and a photo of your setup is enough for us to tell.