Colorimètre / Luxmètre Professionnel OPT4048
Réf : ECO-COLORIM-01 | Version 2.1 — Avril 2026 | En cours de développement
Instrument de mesure colorimétrique et photométrique basé sur le capteur OPT4048 de Texas Instruments. Mesure tristimulus XYZ directe, calcul CCT, coordonnées CIE 1931, plage dynamique 26 bits. ESP32-WROOM-32 + OLED 128×64 + microSD + CH340C (USB-UART auto-reset) + WiFi. PCB 70×50 mm 2 couches.
Vue d'ensemble du projet
Le circuit est conçu et documenté. La fabrication PCB et la validation métrologique sont à venir. Le firmware est fonctionnel sur breadboard.
Pourquoi ce projet ?
Ce projet combine précision professionnelle et accessibilité DIY pour créer un outil polyvalent de caractérisation de la lumière. Le capteur OPT4048 de Texas Instruments offre un matching exact des courbes CIE 1931 grâce à ses 4 canaux XYZ+W — une mesure tristimulus directe, sans conversion approximative depuis un espace RGB.
La version 2.1 ajoute le bridge USB-UART CH340C avec circuit auto-reset à transistors croisés (BC847), rendant la programmation USB native sans manipulations manuelles des strapping pins.
Fonctionnalités principales
- Colorimétrie CIE 1931 : coordonnées chromatiques (x,y) et (u',v'), espace CIE Lab, ΔE entre échantillons.
- CCT double méthode : McCamy (rapide) et Hernández-Andrés (précise, Newton-Raphson sur locus de Planck).
- Photométrie : éclairement de 2,15 mlux à 144 klux — plage dynamique 67 millions:1.
- Interface web WiFi : point d'accès intégré, visualisation temps réel, streaming 500 ms, aperçu sRGB.
- Datalogging microSD : CSV horodaté — XYZ, W, lux, x, y, u', v', CCT×2, Lab, classification source.
- Programmation USB native : CH340C + auto-reset — flash depuis Arduino IDE / PlatformIO sans intervention manuelle.
Capteur OPT4048 — Pourquoi ce choix ?
Canaux XYZ natifs — Matching CIE 1931 exact
Contrairement aux capteurs RGBW comme l'OPT4060, l'OPT4048 dispose de 4 canaux dont les réponses spectrales correspondent directement aux courbes de l'observateur CIE 1931 (X, Y, Z + W). Le calcul de CCT et des coordonnées chromatiques est donc direct, sans approximation par conversion RGB→XYZ.
Caractéristiques capteur
- PackageSOT-5X3 (WSON-8)
- CanauxX, Y, Z + W (CIE 1931)
- Résolution2,15 mlux min.
- Plage2,15 mlux – 144 klux
- Dynamique26 bits (67M:1)
- Temps intégration600 µs – 800 ms (12 modes)
- InterfaceI²C (4 adresses 0x44–0x47)
- Conso. active24 µA actif / 2 µA veille
- Rejection IRExcellente (850/940 nm)
- Prix~4,50 € (Mouser/Digi-Key)
OPT4048 vs OPT4060
- OPT4048 — CanauxXYZ + W (tristimulus)
- OPT4048 — Matching CIEExcellent (direct)
- OPT4048 — CCTDirect XYZ→CCT
- OPT4048 — UsageColorimétrie scientifique
- OPT4060 — CanauxRGBW (approx.)
- OPT4060 — CCTIndirect RGB→XYZ→CCT
- OPT4060 — UsageDétection couleur simple
Règle : OPT4048 si colorimétrie scientifique, CCT précis, mesure photopique. OPT4060 si détection couleur RGB simple ou budget contraint.
Circuit OPT4048 — Connexions
+3.3V
|
[100nF C2]
|
┌─────────────┴────────────────────────┐
│ OPT4048 │
│ (Package SOT-5X3 / WSON-8) │
│ │
│ 1. VDD ──────────────── +3.3V │
│ 2. GND ──────────────── GND │
│ 3. SDA ──[4,7kΩ R1]── +3.3V ├──── MCU SDA (GPIO21)
│ 4. SCL ──[4,7kΩ R2]── +3.3V ├──── MCU SCL (GPIO22)
│ 5. INT ──[10kΩ R3]─── +3.3V ├──── MCU GPIO33
│ 6. ADDR ──────────────── GND │ ← adresse 0x44
│ 7. NC │
│ 8. GND ──────────────── GND │
└─────────────────────────────────────┘
Config adresses I²C (pin ADDR) :
ADDR → GND → 0x44 ← retenue
ADDR → VDD → 0x45
ADDR → SDA → 0x46
ADDR → SCL → 0x47
Architecture Matérielle
ESP32-WROOM-32 + OPT4048 + OLED + microSD + CH340C — PCB 70×50 mm 2 couches
Toute la chaîne de mesure tient sur un PCB compact de 70×50 mm. Le connecteur USB-C assure à la fois l'alimentation/charge et la programmation via le bridge CH340C. L'OPT4048 est positionné en bord de carte avec dégagement mécanique 5 mm.
Affectation GPIO ESP32
- GPIO 21I²C SDA (OPT4048 + OLED)
- GPIO 22I²C SCL (OPT4048 + OLED)
- GPIO 33INT OPT4048 (data ready)
- GPIO 18SPI CLK microSD
- GPIO 19SPI MISO microSD
- GPIO 23SPI MOSI microSD
- GPIO 4SD CS (pull-up 10kΩ)
- GPIO 25BTN1 MODE
- GPIO 13BTN2 SELECT (pull-up 10kΩ)
- GPIO 14BTN3 HOLD (pull-up 10kΩ)
- GPIO 34ADC batterie (input-only)
- GPIO 2LED status bleue
- GPIO 26LED charge rouge
- GPIO 1 / 3U0TXD/RXD → CH340C + J3
- GPIO 0BOOT → Q1 auto-reset
Alimentation
- EntréeUSB-C J1 — 5 V
- ChargeurTP4056 — 1 A
- Batterie18650 Li-ion 3,0–4,2 V
- Rail 3,3 VAMS1117-3.3 — 800 mA
- ProtectionDiode Schottky SS34
- Conso. max~500 mA (WiFi actif)
- Conso. veille~30 mA (light sleep)
- Autonomie>10 h (2500 mAh, sans WiFi)
Bus I²C & adresses
- OPT40480x44 (ADDR → GND)
- OLED SSD13060x3C (SA0 → GND)
- Pull-ups SDA/SCL4,7 kΩ (R1, R2) côté OPT
- Pull-up INT10 kΩ (R3) → GPIO 33
- Adresses dispo.0x44–0x47 (jusqu'à 4 capteurs)
Note : GPIO 19 exclusivement MISO microSD. L'INT OPT4048 a été déplacé sur GPIO 33 pour éviter le conflit initial.
PCB 2 couches
- Dimensions70 × 50 mm
- CouchesTop (signaux) / Bottom (GND)
- Cuivre35 µm (1 oz)
- Viaperçage 0,3 mm / anneau 0,6 mm
- FinitionHASL sans plomb, FR4 1,6 mm
- Zone USBCH340C + connecteur + Q1/Q2 groupés en bord PCB
- FabricantJLCPCB / PCBWay (~2 €/u lot 5)
USB-UART CH340C + Circuit Auto-Reset — Nouveauté v2.1
Programmation USB native — sans manipulation manuelle
La v2.1 intègre le bridge CH340C (sans quartz externe — régulateur 3,3 V interne) et un circuit auto-reset à transistors croisés NPN (BC847). L'ESP32 entre automatiquement en bootloader lors du flash depuis Arduino IDE, PlatformIO ou esptool — exactement comme une carte DevKit classique.
Schéma bloc — Alimentation + USB
USB-C (J1)
│
├── D+ / D─ ────► CH340C (USB-UART) ────► ESP32 UART0 (GPIO1/GPIO3)
│ │
│ [Auto-Reset Q1/Q2] ────► EN + GPIO0
│
└── VBUS 5V ──► D1 (SS34) ──► VSYS
│
┌────────────┴──────────────┐
│ │
TP4056 (chargeur) AMS1117-3.3
│ │
18650 Li-ion Rail 3,3 V ──► ESP32 / OPT4048 / OLED / SD / CH340C(V3)
Circuit auto-reset — Transistors croisés BC847
RTS# ──[10kΩ R9]──► Base Q1(BC847) Collecteur Q1 ──► GPIO0 (BOOT)
Émetteur Q1 → GND │
DTR# ──────────────► Collecteur Q1 [10kΩ R11] → +3.3V
DTR# ──[10kΩ R10]──► Base Q2(BC847) Collecteur Q2 ──► EN (RESET)
Émetteur Q2 → GND │
RTS# ──────────────► Collecteur Q2 [10kΩ] → +3.3V + [100nF C6] → GND
CH340C — Caractéristiques
- PackageSOP-16
- InterfaceUSB Full Speed (12 Mbit/s)
- UART max2 Mbit/s
- Quartz externeNon requis (oscillateur interne)
- Régulateur V33,3 V interne — [100nF C7]
- AlimentationVBUS 5 V + [100nF C8]
- DriversWindows 10/11 natifs, Linux, macOS
- Prix~0,30 € (LCSC)
Composants auto-reset
- Q1, Q2BC847 NPN SOT-23 (~0,02 €)
- R9, R1010 kΩ base Q1/Q2 (0402)
- R1110 kΩ pull-up GPIO0 (0402)
- C6100 nF filtre EN/RESET (0402)
- Connexion DTRCH340C.DTR# → R10.1 + Q1.C
- Connexion RTSCH340C.RTS# → R9.1 + Q2.C
Firmware ESP32 — Architecture logicielle
Colorimétrie CIE 1931
Calcul (x,y) et (u',v'), conversion CIE Lab. Distance ΔE (CIE76) pour comparaison d'échantillons. Seuil perception humaine ≈ 2,3 ΔE.
CCT double méthode
McCamy (immédiat, ±2%), Hernández-Andrés avec itération Newton-Raphson sur locus de Planck. Plage 1 667–25 000 K.
Interface Web WiFi
Point d'accès "Colorimeter" (192.168.4.1). Streaming 500 ms, aperçu sRGB calculé depuis XYZ, téléchargement log CSV.
Datalogging microSD
CSV avec 16 colonnes : timestamp, lux, X, Y, Z, W, x, y, u', v', CCT×2 (McCamy+précis), Lab(L,a,b), classification source.
Calibration EEPROM
Facteur lux + gains XYZ sauvegardés en EEPROM (magic 0xCAFECAFE). Moyennage N=100 mesures. Commande interactive série 'c'.
Tests intégrés
Répétabilité (CV sur N=50), calibration écran D65, binning LED (Warm/Neutral/Cool/Daylight). Commandes série 'm', 't', 'b', 'v'.
Bibliothèques Arduino requises
Classification automatique de la source lumineuse
Code Arduino — Firmware complet
/*
* ============================================================
* Colorimètre / Luxmètre Professionnel OPT4048
* Version 2.1 — Avril 2026 — ECOPHOT
* ============================================================
* Matériel :
* - ESP32-WROOM-32
* - Capteur OPT4048 (I²C 0x44)
* - OLED SSD1306 128×64 (I²C 0x3C)
* - MicroSD (SPI : CLK=18, MISO=19, MOSI=23, CS=4)
* - CH340C USB-UART (programmation auto-reset)
* - Batterie 18650 + TP4056 + AMS1117-3.3
*
* Bibliothèques requises (Library Manager) :
* - SparkFun_OPT4048 (SparkFun Electronics)
* - Adafruit_SSD1306 (Adafruit)
* - Adafruit GFX Library (Adafruit)
* - SD (built-in Arduino/ESP32)
*
* Licence : MIT — ecophot.fr
* ============================================================
*
* Fonctionnalités :
* - Mesure tristimulus XYZ + W directe
* - Photométrie lux (2,15 mlux – 144 klux, 26 bits)
* - Coordonnées chromatiques (x,y) CIE 1931
* - CCT méthode McCamy (rapide)
* - CCT méthode Hernández-Andrés (précise, Newton-Raphson)
* - Conversion CIE Lab + calcul ΔE
* - Longueur d'onde dominante approximée
* - Classification automatique source lumineuse
* - Affichage OLED temps réel
* - Datalogging CSV sur microSD
* - Serveur web WiFi (point d'accès "Colorimeter")
* - Calibration avec sauvegarde EEPROM
* - Tests répétabilité / linéarité
*
* GPIO assignments :
* GPIO 21 → I²C SDA (OPT4048 + OLED)
* GPIO 22 → I²C SCL (OPT4048 + OLED)
* GPIO 33 → INT OPT4048 (data ready, pull-up 10kΩ)
* GPIO 18 → SPI CLK microSD
* GPIO 19 → SPI MISO microSD
* GPIO 23 → SPI MOSI microSD
* GPIO 4 → SD CS (pull-up 10kΩ)
* GPIO 25 → BTN1 MODE
* GPIO 13 → BTN2 SELECT (pull-up 10kΩ)
* GPIO 14 → BTN3 HOLD (pull-up 10kΩ)
* GPIO 34 → ADC tension batterie (input-only)
* GPIO 2 → LED status bleue (strapping, LOW au boot)
* GPIO 26 → LED charge rouge
* GPIO 1 → U0TXD → CH340C RXD + J3 debug
* GPIO 3 → U0RXD → CH340C TXD + J3 debug
* GPIO 0 → BOOT (contrôlé par circuit auto-reset Q1/Q2)
* ============================================================
*/
#include <Arduino.h>
#include <Wire.h>
#include <SPI.h>
#include <SD.h>
#include <WiFi.h>
#include <WebServer.h>
#include <EEPROM.h>
#include <math.h>
#include <SparkFun_OPT4048.h>
#include <Adafruit_SSD1306.h>
#include <Adafruit_GFX.h>
// ============================================================
// Définitions matérielles
// ============================================================
#define SD_CS_PIN 4
#define OPT_INT_PIN 33
#define BTN1_MODE 25
#define BTN2_SELECT 13
#define BTN3_HOLD 14
#define LED_STATUS 2
#define LED_CHARGE 26
#define BAT_ADC_PIN 34
// OLED
#define SCREEN_WIDTH 128
#define SCREEN_HEIGHT 64
#define OLED_RESET -1
// WiFi AP
#define WIFI_SSID "Colorimeter"
#define WIFI_PASSWORD "color123"
// EEPROM
#define EEPROM_SIZE 64
#define CAL_ADDR 0 // Adresse calibration dans EEPROM
// ============================================================
// Structures de données
// ============================================================
struct ColorMeasurement {
float X, Y, Z, W;
float lux;
float x, y;
float u_prime, v_prime;
float CCT;
float dominant_wavelength;
float purity;
uint32_t timestamp;
};
struct LabColor {
float L; // Luminosité (0–100)
float a; // Rouge↔Vert (−128 à +127)
float b; // Jaune↔Bleu (−128 à +127)
};
struct CalibrationData {
float lux_factor;
float X_gain, Y_gain, Z_gain;
float X_offset, Y_offset, Z_offset;
uint32_t magic; // 0xCAFECAFE si données valides
};
// ============================================================
// Variables globales
// ============================================================
OPT4048 colorSensor;
Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, OLED_RESET);
WebServer server(80);
CalibrationData cal;
bool sdReady = false;
bool wifiActive = false;
// ============================================================
// Calibration — EEPROM
// ============================================================
void loadCalibration() {
EEPROM.begin(EEPROM_SIZE);
EEPROM.get(CAL_ADDR, cal);
if (cal.magic != 0xCAFECAFE) {
// Valeurs par défaut (pas de calibration)
cal.lux_factor = 1.0f;
cal.X_gain = cal.Y_gain = cal.Z_gain = 1.0f;
cal.X_offset = cal.Y_offset = cal.Z_offset = 0.0f;
}
}
void saveCalibration() {
cal.magic = 0xCAFECAFE;
EEPROM.put(CAL_ADDR, cal);
EEPROM.commit();
}
// ============================================================
// Initialisation capteur OPT4048
// ============================================================
void initOPT4048() {
Wire.begin(21, 22); // SDA=GPIO21, SCL=GPIO22
if (!colorSensor.begin(0x44)) {
Serial.println("[OPT4048] ERREUR: capteur non détecté!");
display.clearDisplay();
display.setCursor(0, 0);
display.println("OPT4048 absent!");
display.display();
while (1) { delay(500); digitalWrite(LED_STATUS, !digitalRead(LED_STATUS)); }
}
colorSensor.setConversionTime(OPT4048_CONVERSION_TIME_100MS);
colorSensor.setRange(OPT4048_RANGE_AUTO);
colorSensor.setOperatingMode(OPT4048_CONTINUOUS_MODE);
// Interruption data ready sur GPIO33
colorSensor.setInterruptConfig(
OPT4048_CHANNEL_Y,
OPT4048_INT_THRESHOLD_HIGH,
1000
);
Serial.println("[OPT4048] Initialisé OK — adresse 0x44");
}
// ============================================================
// Acquisition mesure brute
// ============================================================
ColorMeasurement acquireMeasurement() {
ColorMeasurement data;
data.X = (colorSensor.getX() + cal.X_offset) * cal.X_gain;
data.Y = (colorSensor.getY() + cal.Y_offset) * cal.Y_gain;
data.Z = (colorSensor.getZ() + cal.Z_offset) * cal.Z_gain;
data.W = colorSensor.getW();
data.lux = colorSensor.getLux() * cal.lux_factor;
data.timestamp = millis();
return data;
}
// ============================================================
// Calculs colorimétriques
// ============================================================
// Coordonnées chromatiques xy (CIE 1931)
void calculateChromaticity(ColorMeasurement &data) {
float sum = data.X + data.Y + data.Z;
if (sum > 0.001f) {
data.x = data.X / sum;
data.y = data.Y / sum;
} else {
data.x = 0.3333f;
data.y = 0.3333f;
}
// Coordonnées u'v' (CIE 1976)
float denom = -2.0f * data.x + 12.0f * data.y + 3.0f;
if (fabs(denom) > 0.0001f) {
data.u_prime = 4.0f * data.x / denom;
data.v_prime = 9.0f * data.y / denom;
} else {
data.u_prime = 0.2105f;
data.v_prime = 0.4737f;
}
}
// Locus de Planck (chromacité du corps noir à température T)
void plankianLocus(float T, float &x, float &y) {
if (T >= 1667.0f && T <= 4000.0f) {
x = -0.2661239e9f / (T*T*T)
- 0.2343589e6f / (T*T)
+ 0.8776956e3f / T
+ 0.179910f;
} else if (T > 4000.0f && T <= 25000.0f) {
x = -3.0258469e9f / (T*T*T)
+ 2.1070379e6f / (T*T)
+ 0.2226347e3f / T
+ 0.240390f;
} else {
x = 0.3333f;
y = 0.3333f;
return;
}
if (T >= 1667.0f && T <= 2222.0f) {
y = -1.1063814f*x*x*x - 1.34811020f*x*x + 2.18555832f*x - 0.20219683f;
} else if (T > 2222.0f && T <= 4000.0f) {
y = -0.9549476f*x*x*x - 1.37418593f*x*x + 2.09137015f*x - 0.16748867f;
} else {
y = 3.0817580f*x*x*x - 5.87338670f*x*x + 3.75112997f*x - 0.37001483f;
}
}
// CCT — méthode McCamy (rapide, ±2% sur 2 000–12 000 K)
float calculateCCT_McCamy(float x, float y) {
float n = (x - 0.3320f) / (y - 0.1858f);
return -449.0f * n*n*n
+ 3525.0f * n*n
- 6823.3f * n
+ 5520.33f;
}
// CCT — méthode Hernández-Andrés avec itération Newton-Raphson (précise)
float calculateCCT_Accurate(float x, float y) {
const float xe = 0.3366f, ye = 0.1735f;
const float A0 = -949.86315f, A1 = 6253.80338f, t1 = 0.92159f;
const float A2 = 28.70599f, t2 = 0.20039f;
const float A3 = 0.00004f, t3 = 0.07125f;
float n = (x - xe) / (y - ye);
float CCT = A0 + A1*expf(-n/t1) + A2*expf(-n/t2) + A3*expf(-n/t3);
// Itération Newton-Raphson (max 5 passes)
for (int i = 0; i < 5; i++) {
float xc, yc, xc1, yc1, xc2, yc2;
plankianLocus(CCT, xc, yc);
plankianLocus(CCT + 10, xc1, yc1);
plankianLocus(CCT - 10, xc2, yc2);
float dx = x - xc, dy = y - yc;
if (sqrtf(dx*dx + dy*dy) < 0.0001f) break;
float dxdT = (xc1 - xc2) / 20.0f;
float dydT = (yc1 - yc2) / 20.0f;
float denom = dxdT*dxdT + dydT*dydT;
if (denom > 0) CCT += (dx*dxdT + dy*dydT) / denom;
}
return CCT;
}
// Conversion XYZ → CIE Lab (illuminant D65)
LabColor XYZ_to_Lab(float X, float Y, float Z) {
const float Xn = 0.95047f, Yn = 1.00000f, Zn = 1.08883f;
const float delta = 6.0f / 29.0f;
const float delta3 = delta * delta * delta;
auto f = [&](float t) -> float {
return (t > delta3) ? cbrtf(t) : (t / (3.0f * delta * delta) + 4.0f / 29.0f);
};
float fx = f(X / Xn);
float fy = f(Y / Yn);
float fz = f(Z / Zn);
LabColor lab;
lab.L = 116.0f * fy - 16.0f;
lab.a = 500.0f * (fx - fy);
lab.b = 200.0f * (fy - fz);
return lab;
}
// Distance colorimétrique ΔE (CIE76)
float deltaE(LabColor lab1, LabColor lab2) {
float dL = lab1.L - lab2.L;
float da = lab1.a - lab2.a;
float db = lab1.b - lab2.b;
return sqrtf(dL*dL + da*da + db*db);
}
// Longueur d'onde dominante approchée (nm)
float calculateDominantWavelength(float x, float y) {
const float xe = 0.3333f, ye = 0.3333f;
float theta = atan2f(y - ye, x - xe);
float lambda;
if (theta > 0 && theta < 1.0f)
lambda = 380.0f + (700.0f - 380.0f) * (theta / 1.0f);
else if (theta < 0)
lambda = 490.0f + fabsf(theta) * 50.0f;
else
lambda = 570.0f + (theta - 1.0f) * 50.0f;
return lambda;
}
// Classification de la source lumineuse
String classifyLightSource(float CCT) {
if (CCT < 2000) return "Bougie";
else if (CCT < 3000) return "Incandescent";
else if (CCT < 4000) return "Halogene";
else if (CCT < 5000) return "Fluor chaud";
else if (CCT < 6500) return "Lumiere jour";
else if (CCT < 10000) return "Ciel couvert";
else return "Ciel bleu";
}
// ============================================================
// Affichage OLED
// ============================================================
void displayMeasurement(ColorMeasurement &data) {
display.clearDisplay();
display.setTextSize(1);
display.setTextColor(SSD1306_WHITE);
display.setCursor(0, 0);
display.print("Lux: ");
display.print(data.lux, 1);
display.setCursor(0, 10);
display.print("CCT: ");
display.print((int)data.CCT);
display.print(" K");
display.setCursor(0, 20);
display.print("x:");
display.print(data.x, 4);
display.setCursor(64, 20);
display.print("y:");
display.print(data.y, 4);
display.setCursor(0, 30);
display.print("X:");
display.print((int)data.X);
display.setCursor(42, 30);
display.print("Y:");
display.print((int)data.Y);
display.setCursor(84, 30);
display.print("Z:");
display.print((int)data.Z);
display.setCursor(0, 40);
display.print(classifyLightSource(data.CCT));
display.setCursor(0, 52);
display.print("t:");
display.print(data.timestamp / 1000);
display.print("s");
display.display();
}
// ============================================================
// Datalogging microSD
// ============================================================
void initSDCard() {
SPI.begin(18, 19, 23, SD_CS_PIN);
if (!SD.begin(SD_CS_PIN)) {
Serial.println("[SD] Erreur initialisation");
sdReady = false;
return;
}
sdReady = true;
// Créer l'en-tête CSV si le fichier n'existe pas
if (!SD.exists("/colorlog.csv")) {
File f = SD.open("/colorlog.csv", FILE_WRITE);
if (f) {
f.println("timestamp_ms,lux,X,Y,Z,W,x,y,u,v,CCT_McCamy,CCT_Precise,Lab_L,Lab_a,Lab_b,source");
f.close();
}
}
Serial.println("[SD] Carte OK");
}
void logToSD(ColorMeasurement &data) {
if (!sdReady) return;
File f = SD.open("/colorlog.csv", FILE_APPEND);
if (!f) return;
LabColor lab = XYZ_to_Lab(data.X, data.Y, data.Z);
float cct_acc = calculateCCT_Accurate(data.x, data.y);
f.print(data.timestamp); f.print(",");
f.print(data.lux, 4); f.print(",");
f.print(data.X, 4); f.print(",");
f.print(data.Y, 4); f.print(",");
f.print(data.Z, 4); f.print(",");
f.print(data.W, 4); f.print(",");
f.print(data.x, 5); f.print(",");
f.print(data.y, 5); f.print(",");
f.print(data.u_prime, 5); f.print(",");
f.print(data.v_prime, 5); f.print(",");
f.print(data.CCT, 1); f.print(",");
f.print(cct_acc, 1); f.print(",");
f.print(lab.L, 2); f.print(",");
f.print(lab.a, 2); f.print(",");
f.print(lab.b, 2); f.print(",");
f.println(classifyLightSource(data.CCT));
f.close();
}
// ============================================================
// Serveur Web WiFi
// ============================================================
ColorMeasurement lastMeasure;
void handleRoot() {
String html = R"rawhtml(<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Colorimetre OPT4048</title>
<style>
body{font-family:Arial,sans-serif;max-width:800px;margin:30px auto;padding:0 16px;background:#f5f5f5}
h1{color:#1a73e8;text-align:center}
.card{background:#fff;border-radius:12px;padding:20px;margin:16px 0;box-shadow:0 2px 8px rgba(0,0,0,.1)}
.val{font-size:2rem;font-weight:bold;color:#1a73e8}
.label{font-size:.85rem;color:#666;margin-bottom:4px}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:12px}
#colorBox{width:100%;height:100px;border-radius:10px;border:1px solid #ddd;margin:12px 0;transition:background .5s}
button{padding:12px 28px;font-size:1rem;cursor:pointer;background:#1a73e8;color:#fff;border:none;border-radius:8px;margin:4px}
button:hover{background:#1557b0}
.stream-on{background:#e53935 !important}
.source{font-size:1.1rem;font-weight:bold;color:#333;text-align:center;padding:8px}
.status{font-size:.8rem;color:#aaa;text-align:center}
</style>
</head>
<body>
<h1>Colorimetre Professionnel — OPT4048</h1>
<div style="text-align:center">
<button onclick="measure()">Mesurer</button>
<button id="btnStream" onclick="toggleStream()">Stream (500ms)</button>
</div>
<div id="colorBox"></div>
<div class="card source" id="source">--</div>
<div class="card">
<div class="grid">
<div><div class="label">Lux</div><div class="val" id="lux">--</div></div>
<div><div class="label">CCT</div><div class="val" id="cct">-- K</div></div>
<div><div class="label">x</div><div class="val" id="cx">--</div></div>
<div><div class="label">y</div><div class="val" id="cy">--</div></div>
</div>
</div>
<div class="card">
<div class="grid">
<div><div class="label">X</div><div class="val" id="X">--</div></div>
<div><div class="label">Y</div><div class="val" id="Y">--</div></div>
<div><div class="label">Z</div><div class="val" id="Z">--</div></div>
<div><div class="label">W</div><div class="val" id="W">--</div></div>
</div>
</div>
<div class="card">
<div class="grid">
<div><div class="label">u'</div><div class="val" id="u">--</div></div>
<div><div class="label">v'</div><div class="val" id="v">--</div></div>
</div>
</div>
<div class="status" id="status">Prêt</div>
<script>
function measure(){
document.getElementById('status').textContent='Mesure en cours...';
fetch('/measure').then(r=>r.json()).then(d=>{
document.getElementById('lux').textContent=d.lux.toFixed(2);
document.getElementById('cct').textContent=Math.round(d.CCT)+' K';
document.getElementById('cx').textContent=d.x.toFixed(4);
document.getElementById('cy').textContent=d.y.toFixed(4);
document.getElementById('X').textContent=d.X.toFixed(2);
document.getElementById('Y').textContent=d.Y.toFixed(2);
document.getElementById('Z').textContent=d.Z.toFixed(2);
document.getElementById('W').textContent=d.W.toFixed(2);
document.getElementById('u').textContent=d.u.toFixed(4);
document.getElementById('v').textContent=d.v.toFixed(4);
document.getElementById('source').textContent=d.source;
let r=3.2406*d.X-1.5372*d.Y-0.4986*d.Z;
let g=-0.9689*d.X+1.8758*d.Y+0.0415*d.Z;
let b=0.0557*d.X-0.2040*d.Y+1.0570*d.Z;
r=Math.min(255,Math.round(Math.pow(Math.max(0,r),1/2.2)*255));
g=Math.min(255,Math.round(Math.pow(Math.max(0,g),1/2.2)*255));
b=Math.min(255,Math.round(Math.pow(Math.max(0,b),1/2.2)*255));
document.getElementById('colorBox').style.background='rgb('+r+','+g+','+b+')';
document.getElementById('status').textContent='Mesure OK — '+new Date().toLocaleTimeString();
}).catch(e=>document.getElementById('status').textContent='Erreur: '+e);
}
let streaming=false,iv;
function toggleStream(){
streaming=!streaming;
const btn=document.getElementById('btnStream');
if(streaming){iv=setInterval(measure,500);btn.textContent='Arrêter';btn.classList.add('stream-on');}
else{clearInterval(iv);btn.textContent='Stream (500ms)';btn.classList.remove('stream-on');}
}
</script>
</body>
</html>)rawhtml";
server.send(200, "text/html", html);
}
void handleMeasure() {
ColorMeasurement data = acquireMeasurement();
calculateChromaticity(data);
data.CCT = calculateCCT_McCamy(data.x, data.y);
data.dominant_wavelength = calculateDominantWavelength(data.x, data.y);
lastMeasure = data;
String json = "{";
json += "\"lux\":" + String(data.lux, 3) + ",";
json += "\"X\":" + String(data.X, 3) + ",";
json += "\"Y\":" + String(data.Y, 3) + ",";
json += "\"Z\":" + String(data.Z, 3) + ",";
json += "\"W\":" + String(data.W, 3) + ",";
json += "\"x\":" + String(data.x, 5) + ",";
json += "\"y\":" + String(data.y, 5) + ",";
json += "\"u\":" + String(data.u_prime, 5) + ",";
json += "\"v\":" + String(data.v_prime, 5) + ",";
json += "\"CCT\":" + String(data.CCT, 1) + ",";
json += "\"source\":\"" + classifyLightSource(data.CCT) + "\"";
json += "}";
server.send(200, "application/json", json);
logToSD(data);
}
void handleLog() {
if (!sdReady || !SD.exists("/colorlog.csv")) {
server.send(404, "text/plain", "Pas de log disponible");
return;
}
File f = SD.open("/colorlog.csv", FILE_READ);
server.streamFile(f, "text/csv");
f.close();
}
void setupWebServer() {
WiFi.softAP(WIFI_SSID, WIFI_PASSWORD);
server.on("/", handleRoot);
server.on("/measure",handleMeasure);
server.on("/log.csv",handleLog);
server.begin();
wifiActive = true;
Serial.print("[WiFi] AP: ");
Serial.print(WIFI_SSID);
Serial.print(" — IP: ");
Serial.println(WiFi.softAPIP());
}
// ============================================================
// Calibration interactive (via port série)
// ============================================================
float readFloatSerial() {
while (!Serial.available()) delay(10);
return Serial.parseFloat();
}
void performCalibration() {
Serial.println("\n=== CALIBRATION OPT4048 ===");
Serial.println("Placer sous source de référence calibrée...");
delay(5000);
float Xs = 0, Ys = 0, Zs = 0;
const int N = 100;
for (int i = 0; i < N; i++) {
ColorMeasurement d = acquireMeasurement();
Xs += d.X; Ys += d.Y; Zs += d.Z;
delay(10);
}
Xs /= N; Ys /= N; Zs /= N;
Serial.print("Entrer lux référence: ");
float lux_ref = readFloatSerial();
cal.lux_factor = lux_ref / (Ys > 0 ? Ys : 1.0f);
Serial.print("\nEntrer CCT référence (K): ");
float cct_ref = readFloatSerial();
(void)cct_ref; // utilisé pour ajustement colorimétrique futur
saveCalibration();
Serial.println("\n[CAL] Calibration sauvegardée en EEPROM.");
}
// ============================================================
// Tests de validation
// ============================================================
void testRepeatability() {
const int N = 50;
float values[N];
Serial.println("[TEST] Répétabilité — N=50 mesures lux");
for (int i = 0; i < N; i++) {
ColorMeasurement d = acquireMeasurement();
calculateChromaticity(d);
d.CCT = calculateCCT_McCamy(d.x, d.y);
values[i] = d.lux;
delay(100);
}
float mean = 0;
for (int i = 0; i < N; i++) mean += values[i];
mean /= N;
float variance = 0;
for (int i = 0; i < N; i++) variance += (values[i] - mean) * (values[i] - mean);
float std_dev = sqrtf(variance / N);
float cv = (mean > 0) ? (std_dev / mean * 100.0f) : 0;
Serial.printf("[TEST] Moyenne=%.2f lux Ecart-type=%.4f CV=%.3f%%\n",
mean, std_dev, cv);
}
// ============================================================
// Application — Calibration écran (D65)
// ============================================================
void calibrateMonitor() {
Serial.println("[MONITOR] Calibration — Mode D65");
Serial.println(" Afficher un blanc 100% sur l'écran...");
delay(3000);
ColorMeasurement white = acquireMeasurement();
calculateChromaticity(white);
const float x_target = 0.3127f;
const float y_target = 0.3290f;
float dx = x_target - white.x;
float dy = y_target - white.y;
Serial.printf("[MONITOR] Blanc mesuré : x=%.4f y=%.4f\n", white.x, white.y);
Serial.printf("[MONITOR] Écart D65 : dx=%.4f dy=%.4f\n", dx, dy);
if (dx > 0.01f) Serial.println("[MONITOR] → Augmenter gain ROUGE");
else if (dx < -0.01f) Serial.println("[MONITOR] → Réduire gain ROUGE");
if (dy > 0.01f) Serial.println("[MONITOR] → Augmenter gain VERT");
else if (dy < -0.01f) Serial.println("[MONITOR] → Réduire gain VERT");
}
// ============================================================
// Binning LED
// ============================================================
enum LEDBin { WARM_WHITE, NEUTRAL_WHITE, COOL_WHITE, DAYLIGHT, OUT_OF_SPEC };
LEDBin classifyLED(float CCT) {
if (CCT >= 2700 && CCT <= 3000) return WARM_WHITE;
if (CCT >= 4000 && CCT <= 4500) return NEUTRAL_WHITE;
if (CCT >= 5000 && CCT <= 6500) return COOL_WHITE;
if (CCT > 6500) return DAYLIGHT;
return OUT_OF_SPEC;
}
String ledBinName(LEDBin bin) {
switch (bin) {
case WARM_WHITE: return "Warm White (2700-3000K)";
case NEUTRAL_WHITE: return "Neutral White (4000-4500K)";
case COOL_WHITE: return "Cool White (5000-6500K)";
case DAYLIGHT: return "Daylight (6500K+)";
default: return "Hors spec";
}
}
// ============================================================
// Lecture tension batterie
// ============================================================
float readBattery() {
// Pont diviseur 100kΩ/100kΩ → Vbat/2 sur GPIO34
// ADC ESP32 12 bits, Vref≈3.3V (non linéaire, calibration recommandée)
int raw = analogRead(BAT_ADC_PIN);
float v = (raw / 4095.0f) * 3.3f * 2.0f;
return v;
}
// ============================================================
// Setup
// ============================================================
void setup() {
Serial.begin(115200);
Serial.println("\n=== Colorimetre OPT4048 v2.1 — ECOPHOT ===");
// GPIO
pinMode(LED_STATUS, OUTPUT);
pinMode(LED_CHARGE, OUTPUT);
pinMode(BTN1_MODE, INPUT_PULLUP);
pinMode(BTN2_SELECT, INPUT_PULLUP);
pinMode(BTN3_HOLD, INPUT_PULLUP);
digitalWrite(LED_STATUS, LOW);
// EEPROM & calibration
loadCalibration();
// OLED
if (!display.begin(SSD1306_SWITCHCAPVCC, 0x3C)) {
Serial.println("[OLED] ERREUR initialisation!");
} else {
display.clearDisplay();
display.setTextSize(1);
display.setTextColor(SSD1306_WHITE);
display.setCursor(0, 0);
display.println("OPT4048 v2.1");
display.println("ECOPHOT");
display.println("Initialisation...");
display.display();
}
// OPT4048
initOPT4048();
digitalWrite(LED_STATUS, HIGH);
// MicroSD
initSDCard();
// WiFi + serveur web
setupWebServer();
display.clearDisplay();
display.setCursor(0, 0);
display.println("Pret!");
display.print("WiFi: ");
display.println(WIFI_SSID);
display.print("IP: ");
display.println(WiFi.softAPIP());
display.display();
Serial.println("[INIT] Démarrage complet. Prêt.");
}
// ============================================================
// Loop principal
// ============================================================
void loop() {
server.handleClient();
static uint32_t lastMeasMs = 0;
static uint32_t lastDispMs = 0;
// Mesure toutes les 500 ms
if (millis() - lastMeasMs > 500) {
lastMeasMs = millis();
lastMeasure = acquireMeasurement();
calculateChromaticity(lastMeasure);
lastMeasure.CCT = calculateCCT_McCamy(lastMeasure.x, lastMeasure.y);
lastMeasure.dominant_wavelength = calculateDominantWavelength(lastMeasure.x, lastMeasure.y);
}
// Affichage OLED toutes les 300 ms
if (millis() - lastDispMs > 300) {
lastDispMs = millis();
displayMeasurement(lastMeasure);
}
// Bouton MODE (GPIO25) — log manuel + Serial
if (digitalRead(BTN1_MODE) == LOW) {
delay(50);
if (digitalRead(BTN1_MODE) == LOW) {
logToSD(lastMeasure);
LabColor lab = XYZ_to_Lab(lastMeasure.X, lastMeasure.Y, lastMeasure.Z);
float cct_acc = calculateCCT_Accurate(lastMeasure.x, lastMeasure.y);
Serial.printf("[LOG] lux=%.2f CCT_mc=%.0f K CCT_acc=%.0f K x=%.4f y=%.4f "
"X=%.2f Y=%.2f Z=%.2f Lab(%.1f,%.1f,%.1f) src=%s\n",
lastMeasure.lux, lastMeasure.CCT, cct_acc,
lastMeasure.x, lastMeasure.y,
lastMeasure.X, lastMeasure.Y, lastMeasure.Z,
lab.L, lab.a, lab.b,
classifyLightSource(lastMeasure.CCT).c_str());
while (digitalRead(BTN1_MODE) == LOW) delay(10); // attendre relâche
}
}
// Bouton SELECT (GPIO13) — test répétabilité sur appui long (>2s)
if (digitalRead(BTN2_SELECT) == LOW) {
uint32_t t0 = millis();
while (digitalRead(BTN2_SELECT) == LOW) delay(10);
if (millis() - t0 > 2000) testRepeatability();
}
// Commandes série interactive
if (Serial.available()) {
char c = Serial.read();
if (c == 'c' || c == 'C') performCalibration();
if (c == 'm' || c == 'M') calibrateMonitor();
if (c == 't' || c == 'T') testRepeatability();
if (c == 'b' || c == 'B') {
// Binning LED sur mesure courante
LEDBin bin = classifyLED(lastMeasure.CCT);
Serial.print("[BIN] ");
Serial.println(ledBinName(bin));
}
if (c == 'v' || c == 'V') {
Serial.printf("[BAT] Vbat=%.2f V\n", readBattery());
}
if (c == '?') {
Serial.println("Commandes: c=Calibrer m=MonitorD65 t=TestRep b=Binning v=Vbat");
}
}
}
Commandes série interactives (115200 baud)
c / C — Lancer procédure de calibrationm / M — Calibration écran D65t / T — Test répétabilité N=50b / B — Binning LED (Warm/Cool/…)v / V — Lire tension batterie? — Aide commandesBTN1 (GPIO25) : log manuel + affichage série | BTN2 (GPIO13) appui long >2 s : test répétabilité
Applications Pratiques
Calibration d'écrans
Mesure du point blanc (D65 cible : x=0,3127, y=0,3290). Guidance automatique R/G/B. Commande 'm' en série.
Test & binning LED
Classification Warm White (2700–3000K) / Neutral (4000–4500K) / Cool White (5000–6500K) / Daylight. Commande 'b'.
Ambiance lumineuse
Cartographie lux/CCT en bureaux, serres, studios. Datalogging continu microSD. Vérification EN 12464.
Comparaison couleurs
ΔE CIE76 entre deux échantillons. Seuil perception humaine ≈ 2,3 ΔE. Peintures, tissus, plastiques.
Horticulture
Mesure spectre lumineux LED de culture. Suivi ratio B/R, intensité PAR approximée, journalisation automatique.
Photographie
Mesure objective CCT des sources studio. Équilibrage des blancs précis. Comparaison flashs et lumières continues.
Bill of Materials v2.1 — Budget estimé ~31 €
| Réf. | Composant | Description | Qté | Package | Prix |
|---|---|---|---|---|---|
| U1 | OPT4048DTSR | Capteur colorimétrique XYZ+W | 1 | WSON-8 | ~4,50 € |
| U2 | ESP32-WROOM-32 | Module WiFi/BT + MCU 240 MHz | 1 | Module SMD | ~3,50 € |
| U3 | AMS1117-3.3 | Régulateur LDO 3,3 V / 800 mA | 1 | SOT-223 | ~0,15 € |
| U4 | TP4056 | Chargeur Li-ion 1 A | 1 | SOP-8 | ~0,20 € |
| U5 | SSD1306 module | OLED 128×64 I²C | 1 | Module 4 pins | ~3,00 € |
| U6 NEW | CH340C | Bridge USB-UART 3,3 V (sans quartz) | 1 | SOP-16 | ~0,30 € |
| D1 | SS34 | Diode Schottky protection VBUS | 1 | SMA (DO-214AC) | ~0,10 € |
| D2, D3 | LED 0805 | Indicateurs charge / status | 2 | 0805 | ~0,05 € |
| J1 | USB-C femelle | Alimentation + programmation | 1 | SMD 16 pins | ~0,50 € |
| J2 | JST-SH 4p | Connecteur OLED I²C | 1 | Pas 1,0 mm | ~0,30 € |
| J3 | Pin Header 4p | UART debug (2,54 mm) | 1 | THT | ~0,10 € |
| J4 | MicroSD TF | Socket push-push avec détection | 1 | 9+2 pins | ~0,60 € |
| J5 | BH-18650 | Support batterie | 1 | THT/SMD | ~0,80 € |
| SW1–3 | Bouton 6×6 mm | MODE / SELECT / HOLD | 3 | THT 4 pins | ~0,15 € |
| SW4 | Bouton RESET | Reset ESP32 | 1 | THT 4 pins | ~0,05 € |
| Q1, Q2 NEW | BC847 | NPN auto-reset (transistors croisés) | 2 | SOT-23 | ~0,04 € |
| R1, R2 | 4,7 kΩ ±1% | Pull-up I²C SDA/SCL | 2 | 0402 | ~0,02 € |
| R3, R4, R5, R6, R9, R10, R11 | 10 kΩ | Pull-ups divers + bases Q1/Q2 | 7 | 0402 | ~0,07 € |
| R7, R8 | 68 Ω | Série LEDs | 2 | 0402 | ~0,02 € |
| C1, C4, C5 | 10 µF / 10 V | Découplage ESP32 + AMS1117 | 3 | 0805 X5R | ~0,15 € |
| C2, C3, C6, C7, C8 | 100 nF | Découplage divers + CH340C | 5 | 0402 X7R | ~0,05 € |
| — | PCB 70×50 mm 2L | HASL sans plomb, FR4 1,6 mm (lot 5) | 5 | — | ~2,00 €/u |
| Total PCB + composants (hors batterie et boîtier) | ~23–26 € | ||||
| Total complet (avec batterie 18650 + boîtier impression 3D) | ~31 € | ||||
NEW = composants ajoutés en v2.1 par rapport à v2.0
Évolutions Futures
Hardware
- Array 3×3 capteurs — cartographie spatiale
- LED de référence interne calibrée (auto-cal)
- Intégration sphère intégrante
- USB-C PD + alimentation stabilisée
- RTC pour horodatage précis
Software
- Calcul CRI (Color Rendering Index) approximé
- Analyse temporelle — flicker, modulation
- Machine learning classification sources
- Compensation température capteur
- Application smartphone Bluetooth
Applications avancées
- Spectrophotomètre 11 bandes (+ AS7341)
- Chromamètre vidéo (monitoring temps réel)
- Photobiomodulation — doses thérapeutiques
- Agriculture de précision — NDVI approximatif
Projet open source — MIT License
Firmware Arduino, schémas KiCad 7/8 et documentation disponibles.
Intéressé par une collaboration, un test ou une commande groupée de PCB ?
🔓 Open source (MIT) • 🧪 Validation métrologique à venir • 📐 PCB KiCad 7/8 • ⚙️ CH340C auto-reset v2.1