教程 · 入门 · 20 分钟
读取用于循迹的红外反射传感器阵列
循迹车如何把一排红外反射传感器的读数变成一个转向误差:模拟输出与数字输出的对比、传感器标定,以及加权平均位置公式。
循迹车的转向质量,就等于它所依据的那个数的质量。那个数来自一排朝地指的红外反射传感器:线是一种颜色,背景是另一种,传感器报告有多少红外光被反射回来。本文把那一排原始读数变成一个单一的、平滑的线位置——也就是你的控制器据以动作的误差。
反射传感器怎么看线
每颗传感器把一颗红外 LED 和一只光敏三极管配在一起。LED 朝下照;光敏三极管测量反射量。哑光的黑线反射的红外很少(读数低),浅色背景反射得多(读数高)。把阵列在一条黑白赛道上扫一遍,每颗传感器就在“在线上”的数值和“不在线上”的数值之间摆动。
有两样东西会让这些原始值逐趟变化:离地高度和环境光。这就是你永远不把阈值写死的原因——你要标定。同一批传感器朝下指着赛道边缘而不是线时,就变成了边缘检测器,而那里漏一次读数就会让机器人输掉比赛。
模拟传感器与数字传感器
- 模拟传感器给出连续值(例如 Arduino ADC 上的 0–1023)。你能知道线离每颗传感器多远,于是可以算出平滑位置并按比例转向。
- 数字传感器在内部阈值之后只报通断。接线更简单,但你丢掉了中间那段信息,所以转向更粗。
对比例式循迹——也就是你用 PID 控制器整定的那一种——要用模拟读数。循迹模拟器建模的就是这个做法。
先标定
跑之前,让每颗传感器都在线和背景上扫一遍,记下最小值和最大值。然后把实时读数按各自的范围归一化到 0–1000:
// Per-sensor calibration captured during a sweep.
int minv[N], maxv[N];
int normalized(int raw, int i) {
long span = maxv[i] - minv[i];
if (span <= 0) return 0; // sensor never saw contrast
long v = (long)(raw - minv[i]) * 1000 / span; // 0 (background) … 1000 (line)
return constrain(v, 0, 1000);
}
逐颗标定能抵消掉 LED 亮度不均和横排上细微的高度差,于是不会有某颗传感器在悄悄地把整个估计带偏。
从读数到一个线位置
现在用一次反射加权平均把整个阵列收敛成一个位置。给每颗传感器一个固定位置(横排上是 …−2、−1、0、+1、+2…),并用它的归一化读数作为权重。结果就是线相对于横排中心的位置——一侧为正、另一侧为负,线正落在中间时为零。
// Sensor positions across the bar, e.g. { -2000,-1000,0,1000,2000 }.
long readLinePosition() {
long weighted = 0, total = 0;
for (int i = 0; i < N; i++) {
int v = normalized(analogRead(pins[i]), i);
weighted += (long)v * position[i];
total += v;
}
if (total == 0) return lastPosition; // line lost — reuse last direction
lastPosition = weighted / total;
return lastPosition; // 0 = centered, sign = which side
}
有两个细节让它健壮:
- 除以读数总和,而不是传感器颗数,于是淡线和粗线会映射到同一个位置刻度上。
- 当所有传感器都丢线时(
total == 0),复用上一次已知位置,而不是返回零。这会让机器人继续朝线消失的方向转,而不是从一个急弯直接开出去。
几颗传感器?
五颗是不错的起点——足够看出方向和大小,同时让数学部分容易检查。低速下三颗就够;竞赛机器人会用八颗或更多,以在高速下获得更细的分辨率。多加传感器主要买到的是急弯上更平滑的位置估计。
完整的标定程序
“先标定”说起来容易,做坏也很容易。下面是整个程序,包括那个防止死通道毁掉估计的保护:
const int N = 8;
const int PIN[N] = {A0, A1, A2, A3, A4, A5, A6, A7};
int minv[N], maxv[N];
void calibrate(unsigned long durationMs) {
for (int i = 0; i < N; i++) { minv[i] = 1023; maxv[i] = 0; }
const unsigned long start = millis();
while (millis() - start < durationMs) {
for (int i = 0; i < N; i++) {
const int v = analogRead(PIN[i]);
if (v < minv[i]) minv[i] = v;
if (v > maxv[i]) maxv[i] = v;
}
}
// A channel that never saw contrast would turn noise into huge normalised
// values and drag the whole estimate toward itself.
for (int i = 0; i < N; i++) {
if (maxv[i] - minv[i] < 50) {
Serial.print(F("channel dead or never crossed the line: "));
Serial.println(i);
}
}
}
**在整个标定窗口里让阵列扫过那条线。**一台静止不动的机器人会把同一个读数同时记成最小值和最大值,于是跨度为零,那颗传感器什么也贡献不了。让机器人原地转、让线在身下扫过三秒是标准做法,而它之所以有效,正是因为每个通道都会压过胶带。
每一趟开始都重新标定,而不是组装时标一次。环境光会变,离地高度会随着电池包移动而变,胶带会积灰。这是循迹车在这张桌子上好用、换一张桌子就跪的最常见单一原因。
干掉环境光
阳光里有大量 940 nm 红外,而光敏三极管分不清它和自己那颗发射管反射回来的光。一台在室内好用、到了窗边就跪的机器人,看到的正是这个。
如果你的阵列的发射管可以关断——大多数有 LEDON 或 CTRL 引脚——修法是完整的,代价是每通道多一次读数:
int ambientSubtracted(int i) {
digitalWrite(LEDON, LOW);
delayMicroseconds(200); // let the phototransistor settle
const int dark = analogRead(PIN[i]); // ambient only
digitalWrite(LEDON, HIGH);
delayMicroseconds(200);
const int lit = analogRead(PIN[i]); // ambient + our emitter
return lit - dark; // just our emitter
}
环境里恒定不变的东西会完全抵消掉。代价是采样时间翻倍,在一个快速循迹车上这是实打实的——但它把一颗不可靠的传感器变成一颗可靠的,而商用阵列用的就是这个技术。
它还有一个值得要的附带好处:发射管在两次读数之间关着,它们的平均电流会急剧下降。8 通道阵列的发射管持续工作时能吃 100–200 mA,在小电池包上那是预算里不容忽视的一块。
丢线,以及怎么找回来
上面那个 total == 0 的情况值得比通常更多的注意,因为循迹车最常见的可见失效就发生在这里。
返回零是显而易见的实现,也是错的那个:零意味着“完美居中”,于是机器人全速直行——离刚刚丢掉的、几乎肯定是弯的那条线越来越远。
返回上一次的位置则让机器人继续朝它本来就在转的方向转,而线就是往那儿去的。仅这一处改动,就是“能从急弯里恢复的机器人”和“开出赛道的机器人”之间的区别。
一个更完整的版本还会加一个超时,让真正丢线的机器人停下来而不是原地打转:
long lastPosition = 0;
unsigned long lostSince = 0;
long readLinePosition() {
long weighted = 0, total = 0;
for (int i = 0; i < N; i++) {
const int v = normalized(analogRead(PIN[i]), i);
if (v > 100) { // ignore channels clearly on the floor
weighted += (long)v * POSITION[i];
total += v;
}
}
if (total == 0) {
if (lostSince == 0) lostSince = millis();
if (millis() - lostSince > 1500) stopMotors(); // genuinely lost
return lastPosition; // keep turning the way we were
}
lostSince = 0;
lastPosition = weighted / total;
return lastPosition;
}
v > 100 这个阈值同样要紧。没有它,八个通道各自读到的一个小小的非零地面值,会不管线实际在哪里都把加权平均拽向阵列中心——表现出来就是一台在粗线上跟得很好、在浅线上跟得很差的机器人。
离地高度决定对比度
反射传感器的甜点区很窄,而它是那个最常被误认为软件问题的机械变量。
| 离地高度 | 你会在标定跨度里看到什么 |
|---|---|
| 1–3 mm | 跨度最大,但地面稍有起伏阵列就会刮到 |
| 3–8 mm | 可用区间。大多数阵列标称约 3 mm |
| 8–15 mm | 跨度明显缩小;归一化开始放大噪声 |
| > 15 mm | 在大多数表面上基本上是瞎的 |
由此有两个后果。刚性安装——装在柔性支架上的阵列会随机器人加速而改变高度,从而改变读数,而控制环会把这些读数变化理解成线在移动。还要装平,因为歪的阵列给每个通道的有效高度不同、于是标定不同,表现出来就是一台一个方向跟得好、另一个方向跟得差的机器人。
几颗传感器,量化一下
| 通道数 | 在 19 mm 线上的位置分辨率 | 适合 |
|---|---|---|
| 3 | 基本就是左 / 中 / 右 | 理解这个想法 |
| 5 | 约 4 个离散区域,中间平滑 | 温柔赛道上你的第一台循迹车 |
| 8 | 约 7 个区域 | 快赛道、急弯、路口检测 |
| 12–16 | 细 | 高速竞赛机器人 |
比颗数更关键的数字,是阵列宽度与线宽之比。对普通循迹来说,阵列要宽到线的每个边缘之外都至少有一颗传感器压在地上——大约两倍胶带宽。
对线迷宫路口检测要求更严:机器人是靠看到最外侧传感器变黑、而中心仍在线上来认出岔路的,所以阵列至少要三倍胶带宽。8 通道、8 mm 间距的阵列约 60 mm,对 19 mm 胶带正好合适。
故障排查
| 现象 | 可能原因 | 解决 |
|---|---|---|
| 这面能用那面就跪 | 标定过期或缺失 | 每趟开始在这面重新标定 |
| 某个通道拖后腿 | 发射管死了——它的跨度极小 | 检查每个通道的 maxv − minv;加上保护 |
| 丢线后径直开出 | 丢线时返回了 0 | 改成返回上一次位置 |
| 粗胶带跟得好、淡胶带乱走 | 地面噪声混进平均 | 加上 v > 100 阈值 |
| 到了窗边就跪 | 环境红外 | 发射管关断相减,或者给阵列加罩 |
| 线越过传感器时位置跳变 | 没有逐通道归一化 | 每通道按自己的最小值和最大值标定 |
| 长跑中读数漂移 | 电池下垂,发射管变暗 | 检查带载时的电源轨 |
| 所有通道读数一样 | 阵列太高 | 降到 3–8 mm,然后重新标定 |
| 一个方向好、另一个方向差 | 阵列没装平 | 每个通道的有效高度都不同 |
接下来去哪
那个单一的位置值就是误差项。把它喂进一个“让一个轮子加速、另一个减速”的控制环,你就有一台循迹车了。那个环在PID 控制器调参里学,然后可以在循迹模拟器里把整件事端到端试一遍。
探索关联图谱
本教程所属的搭建
包含本教程的项目和学习路线。
延伸阅读