Cảm ứng điện trở XPT2046 với ESP32: hiệu chuẩn ba điểm, lọc nhiễu, vẽ nút bấm trên màn TFT
Màn TFT cảm ứng XPT2046 với ESP32: nguyên lý cảm ứng điện trở, nối dây SPI chung, vì sao chạm lệch, hiệu chuẩn ba điểm so với map(), chương trình lưu hệ số vào flash và vẽ nút bấm.
XPT2046 là chip đọc cảm ứng điện trở gắn trên hầu hết màn TFT SPI 2,4–3,2 inch giá rẻ. Lỗi hay gặp nhất với loại màn này là bấm một chỗ, máy nhận chỗ khác: nút bấm lệch vài chục pixel, trục bị ngược, chạm góc này mà nhảy sang góc kia. Bài này giải thích vì sao phải hiệu chuẩn, so sánh cách map() hai điểm với hiệu chuẩn ba điểm bằng mô phỏng, rồi đưa chương trình ESP32 hoàn chỉnh: tự hiệu chuẩn, lưu số vào flash, vẽ nút bấm có phản hồi.
1. Cảm ứng điện trở hoạt động thế nào

Màn có hai lớp phim phủ điện trở, cách nhau bằng các hạt đệm rất nhỏ. Khi ấn, hai lớp chạm nhau. XPT2046 cấp điện áp cho một lớp rồi đọc điện áp trên lớp kia bằng ADC 12 bit, ra số từ 0 đến 4095 tỉ lệ với vị trí. Đảo vai hai lớp để đo trục còn lại, và đo thêm điện trở tiếp xúc để biết lực ấn (số Z).
Từ đó có ba đặc điểm cần nhớ khi lập trình: cần lực ấn nên dùng bút hoặc móng tay tốt hơn đầu ngón; lần đọc lúc vừa chạm hoặc vừa nhả thường lệch, nên phải lọc; và chỉ nhận một điểm, ấn hai điểm thì ra vị trí ở giữa.
2. Nối dây

Trên module thường có hai nhóm chân: nhóm SDI, SCK, SDO, CS cho màn và nhóm T_DIN, T_CLK, T_DO, T_CS cho cảm ứng. Nối chung ba đường dữ liệu và xung nhịp để tiết kiệm chân. SDO của màn và T_DO đều phải nối D19, thiếu dây này thì cảm ứng không đọc được, dù màn vẫn hiện hình.
Cấu hình TFT_eSPI cần thêm dòng #define TOUCH_CS 21 và tốc độ SPI_TOUCH_FREQUENCY 2500000. XPT2046 chỉ chạy được khoảng 2,5 MHz, thư viện tự hạ tốc độ khi đọc cảm ứng rồi trả lại tốc độ cao cho màn. Cách tạo file cấu hình có ở bài cùng cụm về cấu hình TFT_eSPI; phần màn hình ILI9341 xem bài màn hình TFT SPI ILI9341 với ESP32.
3. Vì sao phải hiệu chuẩn, và chọn cách nào
Số thô từ XPT2046 chưa phải pixel: tấm cảm ứng là linh kiện rời dán lên màn nên hơi lệch, hơi xoay; phần viền không tới được giá trị 0 và 4095; trục thô có thể ngược hoặc đổi chỗ so với trục màn. Cách phổ biến là dùng map() theo số đo hai góc:
int x = map(rawY, 3780, 320, 0, 320); // chỉ co giãn từng trục độc lập
int y = map(rawX, 3850, 280, 0, 240);
Cách này xử lý được co giãn và đảo chiều, nhưng không tính độ xoay. Hiệu chuẩn ba điểm tìm phép biến đổi affine, tức 6 hệ số:
x = A·rx + B·ry + C
y = D·rx + E·ry + F
Hai hệ số B và D cho phép mỗi trục màn phụ thuộc cả hai trục thô, nên xử lý được cả xoay, đảo chiều và đổi trục. Chạm 3 điểm trên màn cho 6 phương trình, vừa đủ giải 6 ẩn. Ba điểm phải không thẳng hàng, đặt xa nhau để nhiễu ít ảnh hưởng.

Theo mô phỏng dưới đây, với tấm dán xoay 1,2 độ, map() để lại sai số tới 7,4 pixel ở các góc, còn affine chỉ khoảng 0,8 pixel. Nếu nút bấm lớn thì map() vẫn dùng được; nhưng với bàn phím số trên màn nhỏ, chênh lệch này là khác biệt giữa bấm đúng và bấm nhầm phím. Hiệu chuẩn ba điểm là cách làm phổ biến trong các tài liệu ứng dụng về màn cảm ứng điện trở.
# PYWORLD.VN - So sánh hiệu chuẩn cảm ứng XPT2046: map() hai điểm và phép biến đổi affine ba điểm, trên tấm cảm ứng dán lệch
# PYWORLD.VN - Giả định: màn 320 x 240, tấm cảm ứng xoay 1,2 độ, trục X Y của tấm ngược với màn, nhiễu ADC khoảng 6 đơn vị
# PYWORLD.VN - Chạy: python mo_phong_hieu_chuan.py (chỉ dùng thư viện chuẩn)
"""Công thức ba điểm giống hệt hàm tinhHeSo() trong file .ino."""
import math
import random
RONG, CAO = 320, 240
GOC = math.radians(1.2)
DIEM_CHUAN = [(32, 24), (288, 120), (160, 216)]
def man_sang_tho(x: float, y: float, rng=None) -> tuple[float, float]:
"""Điểm chạm (pixel) sang số ADC thô của XPT2046 theo mô hình tấm dán lệch."""
xq = x * math.cos(GOC) - y * math.sin(GOC)
yq = x * math.sin(GOC) + y * math.cos(GOC)
rx = 3850 - yq * 14.9 # trục thô X ứng với trục Y màn, chiều ngược
ry = 3780 - xq * 10.8
if rng:
rx += rng.gauss(0, 6)
ry += rng.gauss(0, 6)
return rx, ry
def tinh_he_so(man: list, tho: list) -> tuple[float, ...]:
"""Tìm A…F sao cho x = A·rx + B·ry + C, y = D·rx + E·ry + F đi qua đúng 3 điểm."""
(x1, y1), (x2, y2), (x3, y3) = man
(r1, s1), (r2, s2), (r3, s3) = tho
d = (r1 - r3) * (s2 - s3) - (r2 - r3) * (s1 - s3)
a = ((x1 - x3) * (s2 - s3) - (x2 - x3) * (s1 - s3)) / d
b = ((r1 - r3) * (x2 - x3) - (r2 - r3) * (x1 - x3)) / d
c = x3 - a * r3 - b * s3
e = ((r1 - r3) * (y2 - y3) - (r2 - r3) * (y1 - y3)) / d
dd = ((y1 - y3) * (s2 - s3) - (y2 - y3) * (s1 - s3)) / d
f = y3 - dd * r3 - e * s3
return a, b, c, dd, e, f
def ba_diem(he_so, rx, ry):
a, b, c, d, e, f = he_so
return a * rx + b * ry + c, d * rx + e * ry + f
def hai_diem(rx, ry):
"""Cách hay gặp: map() độc lập từng trục theo hai góc đo được, bỏ qua độ xoay."""
r0, s0 = man_sang_tho(0, 0)
r1, s1 = man_sang_tho(RONG, CAO)
return (ry - s0) / (s1 - s0) * RONG, (rx - r0) / (r1 - r0) * CAO
def luoi(so_x: int = 9, so_y: int = 7):
return [(RONG * (i + 0.5) / so_x, CAO * (j + 0.5) / so_y) for j in range(so_y) for i in range(so_x)]
def do_sai(ham, rng) -> list[tuple[float, float, float, float]]:
"""Với mỗi điểm lưới, lấy trung bình 8 lần đo; trả về (x, y, sai x, sai y)."""
ra = []
for x, y in luoi():
tx, ty = 0.0, 0.0
for _ in range(8):
mx, my = ham(*man_sang_tho(x, y, rng))
tx += mx / 8
ty += my / 8
ra.append((x, y, tx - x, ty - y))
return ra
def thong_ke(sai) -> tuple[float, float]:
d = [math.hypot(a, b) for _, _, a, b in sai]
return sum(d) / len(d), max(d)
def chay():
rng = random.Random(3)
tho = []
for x, y in DIEM_CHUAN:
mau = [man_sang_tho(x, y, rng) for _ in range(16)]
tho.append((sum(m[0] for m in mau) / 16, sum(m[1] for m in mau) / 16))
he_so = tinh_he_so(DIEM_CHUAN, tho)
return do_sai(hai_diem, rng), do_sai(lambda r, s: ba_diem(he_so, r, s), rng), he_so
if __name__ == "__main__":
s2, s3, hs = chay()
print("He so A..F:", [round(v, 5) for v in hs])
print(f"map hai diem : sai trung binh {thong_ke(s2)[0]:.1f} px, lon nhat {thong_ke(s2)[1]:.1f} px")
print(f"affine ba diem: sai trung binh {thong_ke(s3)[0]:.1f} px, lon nhat {thong_ke(s3)[1]:.1f} px")
4. Chương trình ESP32
Chương trình dùng TFT_eSPI 2.5.43 trên ESP32, gói esp32 3.3.10. Lần đầu chạy, màn hiện lần lượt ba dấu cộng; chạm đúng tâm bằng bút cảm ứng. Số hệ số được lưu vào flash, lần sau không phải làm lại. Thêm một LED ở chân D25 qua điện trở 330 Ω để thấy nút hoạt động.
// PYWORLD.VN - Màn ILI9341 có cảm ứng XPT2046 với ESP32: hiệu chuẩn ba điểm (affine), lưu vào flash, vẽ nút bấm có phản hồi
// PYWORLD.VN - Dùng TFT_eSPI 2.5.43 với file cấu hình có TOUCH_CS 21; cảm ứng dùng chung SPI với màn, không cần thư viện cảm ứng riêng
// PYWORLD.VN - Giữ nút HIEU CHUAN để đo lại. Lần đầu chạy, chạm đúng tâm từng dấu cộng bằng bút cảm ứng
#include <Preferences.h>
#include <TFT_eSPI.h>
const int CHAN_LED = 25; // LED ngoài, đổi theo mạch của bạn
const uint16_t NGUONG_AP = 300; // lực ấn tối thiểu theo số Z của XPT2046
const int SO_MAU = 7; // lấy trung vị 7 lần đọc
const uint32_t CHO_NHA_MS = 40;
TFT_eSPI tft;
Preferences boNho;
struct HeSo {
float a, b, c, d, e, f;
};
HeSo heSo;
struct Nut {
int16_t x, y, w, h;
const char *chu;
uint16_t mau;
};
Nut nutBat = {20, 150, 130, 70, "BAT", TFT_DARKGREEN};
Nut nutTat = {170, 150, 130, 70, "TAT", TFT_MAROON};
Nut nutChuan = {20, 20, 280, 40, "HIEU CHUAN", TFT_NAVY};
void veNut(const Nut &n, bool an) {
tft.fillRoundRect(n.x, n.y, n.w, n.h, 8, an ? TFT_WHITE : n.mau);
tft.drawRoundRect(n.x, n.y, n.w, n.h, 8, TFT_WHITE);
tft.setTextDatum(MC_DATUM);
tft.setTextColor(an ? TFT_BLACK : TFT_WHITE);
tft.drawString(n.chu, n.x + n.w / 2, n.y + n.h / 2, 4);
}
bool trongNut(const Nut &n, int16_t x, int16_t y) {
return x >= n.x && x < n.x + n.w && y >= n.y && y < n.y + n.h;
}
// Đọc số thô: bỏ qua nếu ấn nhẹ, lấy trung vị để loại các lần đọc lệch khi bút vừa chạm.
bool docTho(uint16_t &rx, uint16_t &ry) {
uint16_t mx[SO_MAU], my[SO_MAU];
for (int i = 0; i < SO_MAU; i++) {
if (tft.getTouchRawZ() < NGUONG_AP) {
return false;
}
tft.getTouchRaw(&mx[i], &my[i]);
}
for (int i = 1; i < SO_MAU; i++) { // sắp xếp chèn, mảng rất ngắn
for (int j = i; j > 0 && mx[j - 1] > mx[j]; j--) {
const uint16_t t = mx[j]; mx[j] = mx[j - 1]; mx[j - 1] = t;
}
for (int j = i; j > 0 && my[j - 1] > my[j]; j--) {
const uint16_t t = my[j]; my[j] = my[j - 1]; my[j - 1] = t;
}
}
rx = mx[SO_MAU / 2];
ry = my[SO_MAU / 2];
return true;
}
// Chạm tại dấu cộng: lấy trung bình 16 lần đọc tốt rồi chờ nhả tay.
void doDiem(int16_t x, int16_t y, float &rx, float &ry) {
tft.fillScreen(TFT_BLACK);
tft.setTextDatum(MC_DATUM);
tft.setTextColor(TFT_WHITE);
tft.drawString("Cham dung tam dau cong", 160, 200 - (y > 120 ? 150 : 0), 2);
tft.drawFastHLine(x - 12, y, 25, TFT_YELLOW);
tft.drawFastVLine(x, y - 12, 25, TFT_YELLOW);
uint32_t tongX = 0, tongY = 0;
int dem = 0;
while (dem < 16) {
uint16_t a, b;
if (docTho(a, b)) {
tongX += a;
tongY += b;
dem++;
}
delay(10);
}
rx = tongX / 16.0f;
ry = tongY / 16.0f;
while (tft.getTouchRawZ() >= NGUONG_AP) {
delay(10);
}
delay(300);
}
void tinhHeSo(const float x[3], const float y[3], const float r[3], const float s[3]) {
const float d = (r[0] - r[2]) * (s[1] - s[2]) - (r[1] - r[2]) * (s[0] - s[2]);
heSo.a = ((x[0] - x[2]) * (s[1] - s[2]) - (x[1] - x[2]) * (s[0] - s[2])) / d;
heSo.b = ((r[0] - r[2]) * (x[1] - x[2]) - (r[1] - r[2]) * (x[0] - x[2])) / d;
heSo.c = x[2] - heSo.a * r[2] - heSo.b * s[2];
heSo.d = ((y[0] - y[2]) * (s[1] - s[2]) - (y[1] - y[2]) * (s[0] - s[2])) / d;
heSo.e = ((r[0] - r[2]) * (y[1] - y[2]) - (r[1] - r[2]) * (y[0] - y[2])) / d;
heSo.f = y[2] - heSo.d * r[2] - heSo.e * s[2];
}
void hieuChuan() {
const float x[3] = {32, 288, 160};
const float y[3] = {24, 120, 216};
float r[3], s[3];
for (int i = 0; i < 3; i++) {
doDiem(x[i], y[i], r[i], s[i]);
}
tinhHeSo(x, y, r, s);
boNho.putBytes("heso", &heSo, sizeof(heSo));
Serial.printf("He so: %.5f %.5f %.2f %.5f %.5f %.2f\n", heSo.a, heSo.b, heSo.c, heSo.d, heSo.e, heSo.f);
}
void veManHinh() {
tft.fillScreen(TFT_BLACK);
veNut(nutChuan, false);
veNut(nutBat, false);
veNut(nutTat, false);
}
void setup() {
Serial.begin(115200);
pinMode(CHAN_LED, OUTPUT);
tft.init();
tft.setRotation(1); // 320 x 240 nằm ngang
boNho.begin("cam-ung", false);
if (boNho.getBytes("heso", &heSo, sizeof(heSo)) != sizeof(heSo)) {
hieuChuan(); // chưa có số trong flash
}
veManHinh();
}
void loop() {
uint16_t rx, ry;
if (!docTho(rx, ry)) {
return;
}
const int16_t x = heSo.a * rx + heSo.b * ry + heSo.c;
const int16_t y = heSo.d * rx + heSo.e * ry + heSo.f;
Serial.printf("tho %u %u -> diem %d %d\n", rx, ry, x, y);
tft.fillCircle(x, y, 2, TFT_YELLOW); // chấm vàng cho thấy điểm máy hiểu, dùng để kiểm tra độ lệch
const Nut *nut = trongNut(nutBat, x, y) ? &nutBat : trongNut(nutTat, x, y) ? &nutTat : trongNut(nutChuan, x, y) ? &nutChuan : nullptr;
if (nut) {
veNut(*nut, true);
}
while (tft.getTouchRawZ() >= NGUONG_AP) {
delay(10); // chờ nhả để mỗi lần ấn chỉ tính một lần
}
delay(CHO_NHA_MS);
if (nut == &nutBat || nut == &nutTat) {
digitalWrite(CHAN_LED, nut == &nutBat ? HIGH : LOW);
veNut(*nut, false);
} else if (nut == &nutChuan) {
hieuChuan();
veManHinh();
}
}

Các điểm chính
docTho()bỏ qua khi lực ấn dưới ngưỡng, rồi lấy trung vị 7 lần đọc. Trung vị loại được các lần đọc lệch hẳn, còn trung bình thì bị chúng kéo theo.tinhHeSo()giải 6 hệ số bằng công thức định thức. Đây là cùng công thức trong script mô phỏng.- Chấm vàng vẽ tại điểm máy tính ra. Chạm vào giữa một nút rồi nhìn chấm: lệch nhiều tức là cần hiệu chuẩn lại. Dự án thật thì bỏ dòng này.
- Chờ nhả tay rồi mới thực hiện lệnh, giống nút thật, và mỗi lần ấn chỉ tính một lần. Nhờ vậy ấn giữ không làm nút chạy lặp.
- Preferences lưu cả cấu trúc hệ số. Thay màn hoặc xoay hướng màn (
setRotation) thì phải hiệu chuẩn lại.
TFT_eSPI có sẵn hàm tft.calibrateTouch() và tft.getTouch(), dùng rất tiện nếu không cần tự xử lý: chạy ví dụ Touch_calibrate để lấy mảng số rồi đưa vào tft.setTouch(). Thư viện XPT2046_Touchscreen của Paul Stoffregen là lựa chọn khi màn không dùng TFT_eSPI. Giao diện phức tạp hơn thì dùng LVGL, trong bài cùng cụm.
5. Xử lý lỗi thường gặp
| Hiện tượng | Nguyên nhân | Cách xử lý |
|---|---|---|
| Màn sáng nhưng cảm ứng không có tác dụng | Chưa nối T_DO, T_CS; chưa khai báo TOUCH_CS | Kiểm tra dây D19, D21 và file cấu hình |
| Số thô luôn 0 hoặc 4095 | Dây T_CLK, T_DIN sai; SPI quá nhanh | Kiểm tra dây, để SPI_TOUCH_FREQUENCY 2500000 |
| Chạm chỗ này, nhảy chỗ khác | Chưa hiệu chuẩn, hoặc đổi setRotation sau khi hiệu chuẩn | Hiệu chuẩn lại ở đúng hướng màn |
| Điểm nhảy lung tung lúc nhả tay | Đọc khi lực ấn yếu | Tăng NGUONG_AP, tăng SO_MAU |
| Nhiễu khi màn đang vẽ | Hai thiết bị tranh SPI từ task khác nhau | Đọc cảm ứng và vẽ trong cùng một task |
| Lệch ở một vùng sau thời gian dài | Tấm cảm ứng bị xước, lão hóa hoặc mạch bị ép cong | Không để vỏ hộp ép lên màn; chạy lại hiệu chuẩn |
6. Xem thêm
Sản phẩm dùng trong bài: ESP32 DevKit V1 30 chân. Website chưa bán màn TFT cảm ứng.
Bài cùng cụm chủ đề: cấu hình TFT_eSPI; LVGL là gì; Cheap Yellow Display.
Xem thêm