教程 · 入门 · 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…),并用它的归一化读数作为权重。结果就是线相对于横排中心的位置——一侧为正、另一侧为负,线正落在中间时为零。

机器人横排上五颗反射传感器的条形图。线落在中心偏右,所以右侧的传感器读数最强。虚线标出横排中心(误差为零),实线标出反射加权位置为正 0.69,也就是算出的转向误差。
传感器读数的加权平均给出一个平滑的线位置:这里线落在中心偏右,所以误差为正,控制器朝零转向。 下载 SVG
// 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 控制器调参里学,然后可以在循迹模拟器里把整件事端到端试一遍。

探索关联图谱

本教程所属的搭建

包含本教程的项目和学习路线。

延伸阅读

参考资料