|
<< Click to Display Table of Contents >> An Accurate LUX Meter Using a Cheap LDR - 2025.09.10 |
![]() ![]()
|
'Lux' is a measurement of light density. It has nothing to do with soap.

1 lux is 1 lumen-per-square-meter (lm/m²). So to get an accurate lux measurement, you'd need a one-meter-square sensor. But thankfully, if you assume the same light density over the entire square meter, then you can get an approximate lux reading from a much smaller sensor.
The old units of light measurement were 'foot-candles' (fc), 1 fc = is 1 lumen per square foot. To get foot-candles, divide the lux by 10.76391. And 1 candle power (cp) = 0.981 candelas (cd). The definitions are complicated, see Wackypodia.
Real lux meters can be expensive, and most of them can't be used in the bath. So here are two simple circuits that use an ultra-cheap Light Dependent Resistor LDR (also known as a photosensitive resistor), with calibrated C++ code, to measure light and return a reasonably accurate linear lux value over a very wide range. From high-power flood lights (>100000 lux), direct sunlight (>10000 lux), down to almost absolute darkness (<0.001 lux). This goes well beyond the range of most lux meters. It's done using a simple voltage divider with a "range selector" resistor to extend the range and accuracy of the LDR, and a logarithmic equation to convert ohms to lux.
The resistance of an LDR can vary from about 20 ohms in very bright light, to over 40Meg ohms in complete darkness, but each LDR model has a different range. The ratio between resistance and lux is wildly non-linear, see the chart below which was plotted by Excel.
LDRs can be read much faster than most digital light sensors such as the TSL2591 or LTR507, which take 100..2000 milliseconds to obtain a reading. Reading an LDR takes microseconds, depending on the read speed of the MCU's analog input. But LDRs have a "slow" response to fast light changes, 2..50 milliseconds, so reading them too quickly may not make sense. This is because LDRs have a kind of memory that slows the response if there are few changes, i.e. if it's been in the dark for hours, when it first sees light the response time may be up to 50 milliseconds, but normally it will be much faster. But this is fast enough for me!
Most LDRs are designed to be sensitive only to visible light. Whereas digital sensors (these use phototransistors or photodiodes) are also affected by invisible infrared light, so you may need [suspicious or inaccurate] code to remove the infrared light component from the readings of the digital sensor. This is why digital sensors contain two detectors, one for visible and IR light and one for only IR light, so the IR light component can be subtracted. This is difficult, because the proportions of IR light picked up by each sensor are different. The LTR507 does this for you and returns an internally calculated lux value, but the TSL2591 needs library code to subtract the IR component and calculate the lux reading. The code to do this in the couple of official libraries that I found is either wrong or very dubious. See the Ambient Light Sensors post for details.
Some light sensors are affected by ultraviolet light too, but they treat it as visible light even though we may not be able to see it.
All lux sensors are directional, slight changes to the orientation of the sensor can make large changes to the reading. This makes comparing readings from different sensors very difficult. LDRs can also detect light from the sides, so they are less directional than other sensors and can show higher readings for light that arrives at an angle.

A simple voltage divider is used to read the LDR resistance, with a selectable range resistor R2. This is automatically selected by the software according to the LDR resistance to get the best range and accuracy.

R1 = ((R2 * 3.3V) - Vout) / Vout
but we don't need the voltages, we can just use the analogue input reading directly...
R1 = ((R2 * max_analog_count) - analog_reading) / analog_reading)
See the readResistance() method in the code.
To switch in the range resistor according to the light level, we can use discrete transistor or MOSFET switches, or an integrated "analog multiplexer" chip.
Arduinos typically have low impedance inputs (35Meg ohms) and an LDR can have a resistance of up to 40Meg ohm in darkness. This means that the Arduino's input impedance will greatly affect the voltage divider. To solve this problem, an op amp is used as a x1 high-to-low impedance converter. If you're using an ESP32 then you won't need the op amp because this chip has very high impedance inputs (Giga ohms!). The ADA4051 and MCP6002 op amps work well, but I tried it with an old LM358 and it didn't work.
Both the circuits below need four digital outputs to select the range resistors: 1Meg, 100k, 10k and 1k. These can be 1% resistors - the resistances do not need to be exact because their actual resistance will be measured in circuit and entered into the code. Alternatively, a GPIO expander (I2C or SPI) could be used to select the resistor, see MCP23xxx 8/16-Bit I2C/SPI GPIO Expander.
The first circuit can either use four N-channel MOSFETs (e.g. 2N7000), or four NPN transistors (e.g. 2N3904). To use NPN transistors instead of MOSFETs, just change the base (gate) resistors (R5..R8) to 4.7K ohms. This circuit uses an ADA4051 op amp (tiny SOT-23 package), which has a very high input impedance (250 Giga ohms).

The second circuit uses an analog multiplexer chip, an old CD4066 (quad bilateral switch). But a CD4051 or 74HC4051 (8-channel analog multiplexer) could be used instead, needing only 2 Arduino outputs instead of 4. An MCP6002 op amp is used, available in a standard DIP-8 package. This contains two op amps, but we need only one, so the other must have its inputs correctly biased (R1, R2) to keep it stable.

Here are the prototypes, made on my CNC 3018 machine, with the MCP6002 op amp. The white wires are tracks that could not be routed on a single sided board or to fix breaks in the GND backplane.

Here's the one with the tiny ADA4051 op amp (SOT-23 package). My CNC machine is able to cut the narrow tracks for SMD chips, and I can just about solder them too.

These simple prototype board layouts were done with KiCad. The CNC code was produced with FlatCAM, and the CNC 3018 machine was controlled with Candle.
Calibration
The actual range resistor values can be measured in-circuit using an accurate ohm meter. Connect the black lead of the meter to GND, and the red lead to the LDR2 connector which connects to all the resistors. Write a simple program to select each resistor in turn by turning on its controlling output (see ldr-lux-meter.ino below, call setR2()), and note the resistance. Enter the resistance values into the code:
// Calibrated R2 resistance values
// CD4066 version, MCP6002 op amp
ulong r1k = 1201;
ulong r10k = 10210;
ulong r100k = 100400;
ulong r1000k = 1001000;
/*
// 2N7000 version, MCP6002 op amp
ulong r1k = 1006;
ulong r10k = 10000;
ulong r100k = 100300;
ulong r1000k = 996000;
// Defaults
ulong r1k = 1000;
ulong r10k = 10000;
ulong r100k = 100000;
ulong r1000k = 1000000;
*/
The circuit can be tested by connecting known resistor values in place of the LDR and reading the resistance.
The LDR resistance is not linear, but the lux value is, so even if it's not perfectly accurate (we don't use a 1m² sensor), it is still more useful than the raw resistance.
To calculate the lux value from the resistance, call convertResistanceToLux(ulong ohms).
The conversion equation was found with Excel using readings from a cheap LUX meter, a Voltcraft LX-10.
<why did you make a lux meter if you already had one? - ed>

Make a list of resistances vs. lux readings over a wide range of light levels, and enter them into Excel. See the Excel screenshot below, the resistances are in column A and the lux readings are in column B.
LDRs are not affected much by infrared light, but other lux sensors are affected to varying degrees. Use a light source with a very low infrared component.
I used a powerful LED flood light with a light dimmer to vary the brightness. The flood light was in a box with the Lux meter and the LDR mounted at the same end of the box to pick up the light reflected from the white paper reflector at the other end of the box. A piece of black card prevented the direct illumination of the sensors by the flood light. The LDR was connected to an accurate ohm meter to measure its resistance. The LDR and LX-10 were mounted at almost the same distance from the end and sides of the box. Keep the LDR away from the lamp to prevent it heating up, which might cause its resistance to drift a little.
Instead of an LX-10, you could use a BH1750 module, this seems accurate and can output a digital lux value directly. See the Ambient Light Sensors post for other sensors you could use.

Now you can plot the "trend line" for the readings...
- Select all the data values in the two columns and do 'Insert -> Charts -> Scatter chart with smooth lines and markers'. You should see a chart with lux on the vertical Y axis and the resistance on the horizontal X axis.
- Right-click on the curve to select it and show the context menu. Choose 'Add Trendline'. You should see the 'Format Trendline' properties window on the right.
- On the 'Trendline Options' properties page, select the 'Power' radio button. Choose the one that shows the trend line curve with the closest match to your data points.
- Select the 'Display Equation on Chart' checkbox. This shows the equation that matches the curve, e.g. y = 3E+07x-1.34
As you can see, this has quite steep curves at the low and high ends, and its only accurate for a small part of the range (300..50 lux).
But if you plot the log of each column, you will get a linear equation - a straight line.
Create columns D and E with the LOG10() of each value from columns A and B, and repeat the steps above to draw a new graph. Show and note down the linear equation, e.g. y = -1.3395x + 7.4963.
Code this equation into the convertResistanceToLux() function, see below. Because it uses log10(), floating point data must be used. An MCU with a floating point unit FPU is best for this.

Here's the code for the lux calculation. This is valid for my LDR (unknown type), it may not be accurate for other LDR types.
float LDR::convertResistanceToLux(ulong ohms)
{
// using the LX-10 readings, Excel calculated the 'Power' trend
// line with this equation:
// y = 3E+07x-1.34
//float luxf = 3e7f * powf((float)ohms, -1.34f);
// but if you plot the trend line for log(lux) and log(ohms)...
// then it's a linear trend line with a simple linear equation
// y = -1.3395x + 7.4963
float log10lux = (-1.3395f * log10((float)ohms)) + 7.4963f;
float luxf = powf(10.0f, log10lux);
return luxf;
}
The C++ code was written and tested on an STM32 Nucleo board, but it should run on most Arduino-style boards with minimal changes. If you don't have floating point support in sprintf(), you can use these floating point routines.
The software selects the best value for the range resistor according to the LDR resistance. This is done automatically as the resistance changes, see readResistance() and setOptimalR2().
If you're not worried about slowing the reaction time, you can use the RollingAverage class to smooth out the readings.
⬛ LDRLuxSensor.ino [Click to show/hide the code]
|
Here's the LDR class on its own.
⬛ LDR.h [Click to show/hide the code]
|

A nice range of Light Dependent Resistors from Advanced Photonix
https://www.advancedphotonix.com/our-products/light-dependent-resistor-ldr
AD4051 op amp
https://www.analog.com/media/en/technical-documentation/data-sheets/ada4051-1_4051-2.pdf
MCP6001 op amp
https://ww1.microchip.com/downloads/en/DeviceDoc/MCP6001-1R-1U-2-4-1-MHz-Low-Power-Op-Amp-DS20001733L.pdf
CD4066 Quad bilateral switch
https://www.ti.com/lit/ds/symlink/cd4066b.pdf
CD4051 8-channel analog multiplexer
https://www.ti.com/lit/ds/symlink/cd4053b.pdf
74HC4051 8-channel analog multiplexer
https://www.ti.com/lit/ds/symlink/cd74hc4051-ep.pdf