TSL1401线性CCD智能车:MC9S12XS128阈值分割与PD/PID
2026/9/17 12:41:17 网站建设 项目流程

简介:面向智能车竞赛与嵌入式控制方向的一份技术论文型PDF,围绕基于线性CCD的智能小车控制系统设计展开,适合参加NXP杯等智能汽车竞赛的学生、负责循迹小车项目的开发者,以及需要参考赛道识别与控制算法实现思路的读者。文档以MC9S12XS128单片机为控制核心,系统梳理硬件与软件两大部分:硬件涉及电源、TSL1401线性CCD图像采集、$3010舵机转向、欧姆龙编码器测速和MOS管双电机驱动等模块;软件则讲解固定阈值与跳变沿边缘提取、分段PD转向控制以及增量式PID速度闭环等方法,并给出电路组成与算法流程。资源包仅含1个PDF文件,约139KB,轻量便于快速查阅与打印。目前已有122人学习,可作为竞赛方案设计、课程设计或智能系统开发的参考文献,帮助读者把握从传感器采集到执行控制的完整链路与调参要点。

1. 一条黑线、128 个像素,为什么阈值比分辨率更重要

跑过 NXP 杯的人大多有过这样的体验:拿摄像头方案调了半个月,图像处理代码越写越厚,车反而越跑越飘;换成线性 CCD 之后,代码量砍掉一大半,圈速却上来了。TSL1401 这种 128×1 的线性 CCD 只输出单行灰度值,没有形状、没有坐标,信息量比起摄像头少得可怜,但它的前瞻能做到一米开外,帧率轻松拉到几百赫兹,几乎不占 CPU 时间。真正决定成败的不是传感器本身的像素数,而是你从这 128 个数字里怎么把"车现在偏了多少"抠出来。这份以 MC9S12XS128 为核心、在白色赛道上识别两侧黑色引导线的设计,用的是一条很朴素的路线:固定阈值分割、跳变沿搜索边界、非线性位置式 PD 打方向、增量式 PID 控速度。它适合做智能车、循迹小车以及任何需要低成本、高实时性视觉反馈的控制类项目。下面按硬件链路、图像处理、控制算法、实车调试的顺序拆开讲。

2. MC9S12XS128 与 TSL1401 的硬件链路搭建与信号隔离

智能小车的硬件问题,八成会在软件上以"数据莫名其妙抖动"的形式出现。CCD 灰度值整行漂移、编码器脉冲偶尔多计一个、舵机在电机换向瞬间抽一下,这些现象追到根上基本都是电源和地的问题,而不是算法问题。所以在写第一行图像处理代码之前,先把电源域和信号链理顺。

2.1 电源域划分:5V、6V、7.2V 各走各的

小车用 7.2V 电池组供电,但车上其实有三个电压域,混在一起就是灾难。单片机、CCD、编码器要 5V,舵机要 6V,电机驱动直接吃 7.2V。

供电对象电压稳压器件关键注意点
单片机、CCD、编码器5VLAS1117M3 + 滤波电容模拟地与数字地单点汇接
舵机转向模块6VAS1117M3-Adj堵转电流大,单独走粗线
电机驱动模块7.2V电池直供回流路径不要穿过 5V 地平面

常见做法是把舵机和电机的地回流单独拉一条线回到电池负极,在靠近电池的位置和单片机地单点汇接,而不是让大电流从 CCD 的地线上过。舵机堵转那一瞬间的电流突变会在共用地线上产生几十毫伏的压降,如果 CCD 的模拟地正好落在这段上,采样出来的 128 个灰度值会整行跳几个 LSB,阈值判据立刻失效。

2.2 TSL1401 的 SI/CLK 时序与曝光时间标定

TSL1401 内部是 128 个光电二极管加电荷放大电路,还有一个像素数据保持功能。它对外的接口极其简单:一个串行输入 SI,一个时钟 CLK,一个模拟输出 AO。SI 配合第一个 CLK 把积分电容复位,之后每来一个时钟上升沿,就把下一个像素的电压推到 AO 上。

// TSL1401 采集: 128 像素, SI 启动积分, CLK 逐个读出 #define EXPOSURE_US 800 // 积分时间, 强光场地 500~1000us 之间调 #define PIX_NUM 128 uint8_t pixel[PIX_NUM]; void ccd_capture(void) { uint8_t i; SI = 1; // 拉高 SI, 配合第一个 CLK 复位积分电容 CLK = 1; SI = 0; // SI 只保持一个时钟周期的高电平 CLK = 0; delay_us(EXPOSURE_US); // 积分时间, 直接决定整行灰度的基线 for (i = 0; i < PIX_NUM; i++) { CLK = 1; // 上升沿把第 i 个像素推到 AO delay_us(1); // 等 AO 建立稳定 pixel[i] = adc_read(CH0); // CLK 高电平期间采样 CLK = 0; } }

逻辑上,SI与第一个CLK的配合决定了积分起点,之后CLK的个数就是像素个数,多打一个时钟就多读一个无效数据,少打一个就少读一列。EXPOSURE_US是最需要现场标定的参数:曝光越长整行数值越大。这份设计的场地里白色路面灰度稳定在 200 上下、黑色引导线稳定在 70 左右,这两个数就是判断曝光是否合适的标尺——如果白路面采样值只有 120,说明曝光太短或者环境光不足,往上加;如果白路面直接饱和到 255,说明曝光过头,往下减。adc_read()的采样点必须落在CLK高电平期间,落在低电平区间会读到上一列残留的电荷。

2.3 电机驱动、74LVC245 隔离与编码器测速链路

电机驱动用 MOS 管搭双电机 H 桥,单片机输出四路 PWM 控制正反转。这里有一个容易被省掉但很值钱的器件:74LVC245 总线驱动芯片。它干了三件事——提高单片机 IO 的驱动能力,让 MOS 栅极能被快速充放电;把 MOS 与单片机隔开;万一 MOS 击穿,电池电压会先打在 245 上而不是直接灌进单片机引脚烧芯片。省掉这颗芯片,一次 MOS 损坏就可能带走整块最小系统板。

PWM 频率的选择也有讲究。舵机是 50Hz 的标准信号,周期 20ms,靠 0.9~2.1ms 的高电平宽度对应转角;电机 PWM 至少要在 10kHz 以上,落在人耳不敏感的区域,否则电机全程啸叫。

// 舵机 50Hz(周期 20ms), 电机 10kHz(周期 100us) void pwm_init(void) { PWME = 0x00; // 配置寄存器前先关通道 PWMPRCLK = 0x03; // 时钟预分频, 决定 PWMPER 的单位 PWMPOL = 0xFF; // 高电平有效 PWMCAE = 0x00; // 左对齐输出 PWMPER0 = 200; // 舵机通道: 周期计数, 对应 20ms PWMDTY0 = 15; // 1.5ms 高电平 -> 舵机中值 PWMPER1 = 100; // 电机通道: 周期计数, 对应 100us PWMDTY1 = 0; // 初始占空比 0, 上电不冲车 PWME = 0x07; // 使能通道 0/1/2 }

占空比等于PWMDTY/PWMPER,所以PWMDTY0 = 15得到的就是 1.5ms 中值。上电初始把电机占空比写成 0,是一条用血换来的经验:很多车第一次上电就窜出去撞墙,就是因为 PWM 寄存器复位值不确定。

测速用欧姆龙编码器,输出轴齿轮和电机齿轮啮合,转速与电机转速严格成正比。读取方式用 DMA 计数,让定时器在固定周期(比如 10ms)自动搬运脉冲计数值,CPU 完全不参与,省下来的时间全部留给图像处理。

3. 128 像素灰度图像的固定阈值分割与跳变沿边界提取

图像处理这一层的输入是一行 128 个字节,输出是一个数:车相对于赛道中线的偏差。中间所有动作都要在这个约束下完成——不能有大数组、不能有浮点、不能有几十毫秒的耗时。

3.1 为什么用固定阈值而不是动态阈值

看到"光照会变",很多人的第一反应是上动态阈值,每帧统计一次灰度分布,取个中间值当门限,甚至上大津法。在室内比赛场地里,这个思路往往是负优化。场地光照均匀且强度高,白路面和黑线的灰度分布极其稳定,而动态阈值的门限是每帧单独算的,一旦某一帧出现一小块高亮反光或者镜头边缘的暗角,门限就被这几个畸点带偏,本来该判白的路面被判成黑,边界瞬间错位十几列。下一帧门限又回正,舵机就跟着抽一下。

固定阈值把边界判断的稳定性从"每帧自适应"变成了"整场恒定",代价是换场地要重新标定。这个代价完全值得,因为标定只需要在上电前看一眼 128 点波形。更进一步的用法是准备多个固定阈值,针对直道、起跑线、障碍、十字这些不同赛道元素各用一套判定门限,让小车的识别在不同元素前都有明确的反应,而不是靠一条门限硬扛所有情况。

3.2 跳变沿法:只在上一帧中心附近找边沿

有了阈值,接下来是从 128 个值里找黑线的左右边界。最直接的做法是全行扫描,找到所有白到黑的下降沿和黑到白的上升沿。这个方法在十字路口会彻底失效——十字中间那条垂直的黑色引导线会在图像里贡献出额外的两个边沿,全行扫描分不清哪个是自己要跟的那条线。

解决办法是限制搜索范围。上一次引线中心在哪,下一次就在那个位置附近找,窗口取正负 40 个像素。十字的垂直黑线距离本车引导线通常在这个窗口之外,天然被滤掉;引线外围偶然出现的黑色噪点也进不了窗口。

#define PIX_NUM 128 #define CENTER 63.5f // 128 像素的几何中线 #define WIN 40 // 搜索窗口半宽 int16_t last_center = 63; // 上一帧引线中心, 初值取中线 uint8_t last_valid = 0; /* 从上一帧中心附近向左/右搜索跳变沿, 返回本帧引线中心 */ int16_t trace_edge(const uint8_t *px, uint8_t th) { int16_t i, left = -1, right = -1; int16_t lstart = last_center - WIN; int16_t rstart = last_center + WIN; if (lstart < 0) lstart = 0; if (rstart > PIX_NUM - 1) rstart = PIX_NUM - 1; /* 向左: 白 -> 黑的下降沿, 即左边线 */ for (i = lstart; i > 0; i--) { if (px[i - 1] >= th && px[i] < th) { left = i; break; } } /* 向右: 黑 -> 白的上升沿, 即右边线 */ for (i = rstart; i < PIX_NUM - 1; i++) { if (px[i] <= th && px[i + 1] > th) { right = i; break; } } if (left >= 0 && right >= 0 && right > left) { last_center = (left + right) >> 1; // 两边都找到才更新中心 last_valid = 1; } /* 边沿丢失(十字/盲区)时不更新, 沿用上次中心保持直行 */ return last_center; }

WIN是整个算法最敏感的参数。窗口太窄,急弯时黑线跑出搜索范围,算法直接丢线;窗口太宽,十字的垂直线会进入视野被误判成边界。40 这个值是在前瞻约一米、弯道最小曲率半径已知的前提下试出来的,实际调车时可以从 30 开始往大放,放到十字开始误判就退回来两格。丢边沿时的处理同样关键:left_valid标记的作用是让算法在找不到边沿时沿用上一次的中心,而不是返回 -1 让上层去做异常处理——十字路口本来就该直行通过。

3.3 偏差量计算与赛道元素判定

拿到左右边界后取平均就是当前引线的中心位置,与 63.5 相减得到偏差量err。这个数同时被两个环使用:给舵机做转向输入,给速度环做赛道类型判断。

赛道元素判定依据建议阈值处理策略
直道连续若干帧 |err| 小于 8150目标速度拉满
弯道|err| 持续单向增大150降速, 切远场 PD 系数
十字左右边沿同时丢失超过 3 帧170沿用上次中心, 直行不改向
起跑线全行黑像素占比超过 0.8110维持当前速度, 标记一圈
障碍边沿位置出现固定宽度缺口的跳变170按缺口方向绕行

阈值列的数字就是判黑的门限,不同元素用不同门限的原因在于 CCD 对黑线的响应会随距离变化:近距离黑线压得很低,70 左右;远距离因为积分和散射,黑线灰度会抬到 110 甚至 130,用同一个 150 去判,远端边界会提前被切掉,转弯时就会觉得车"提前反应了"。把远端的门限单独抬高,等于给前瞻做了一次距离补偿。

4. 非线性位置式 PD 转向与增量式 PID 速度环整定

控制这一层是整个系统里最需要反复试的部分。图像处理给出一个偏差值,控制算法要把它变成一个舵机 PWM 和一个电机 PWM,而这两个输出之间还会互相影响:车转得急了速度掉得快,速度掉下来转向响应又变慢。

4.1 非线性位置式 PD:为什么偏差要平方

标准的位置式 PD 是out = center + kp*err + kd*(err - err_last)。它的毛病在于比例项是线性的:直道上偏差本身很小,可能只有一两个像素,但传感器噪声也有一个像素,kp 一大舵机就抖,kp 一小弯道又打不动。直道平稳和弯道灵敏这两个需求,用一组线性系数调不出来。

这份设计用偏差的平方代替偏差本身,形式上是out = center + kp*err²*sign(err) + kd*(err - err_last)。偏差小的时候平方项小得几乎不起作用,噪声被压掉;偏差大的时候平方项迅速放大,舵机大幅度打角。非线性的本质是把"小偏差不敏感、大偏差敏感"这个需求直接写进了算法形式里,而不是靠调参去折中。

#define STEER_MID 1500 // 舵机中值: 1.5ms 对应计数 #define STEER_MAX 2100 #define STEER_MIN 900 /* 分段非线性位置式 PD, err 单位为像素 */ int16_t steer_pd(int16_t err) { static int16_t err_last = 0; int16_t kp, kd; int32_t out; /* 分段: 按偏差绝对值切换比例/微分系数 */ if (err > 25 || err < -25) { kp = 45; kd = 14; } // 离场: 大增益抢方向 else { kp = 20; kd = 6; } // 近场: 小增益保平稳 /* 平方项保留符号, >>4 是缩放, 避免乘完溢出 */ out = STEER_MID + kp * (((int32_t)err * err) >> 4) * (err > 0 ? 1 : -1) + kd * (err - err_last); err_last = err; if (out > STEER_MAX) out = STEER_MAX; // 舵机机械行程限幅 if (out < STEER_MIN) out = STEER_MIN; return (int16_t)out; }

这段代码有两个坑必须说明。第一,err * err一定要先转成int32_t再乘,偏差最大能到 60 左右,平方是 3600,再乘 kp 之后远超 16 位范围,用int16_t计算会静默溢出成负数,表现出来就是"弯道往反方向打"。第二,>>4这个缩放系数不是可有可无的修饰,它决定了平方项的量纲能落进舵机行程范围里,改 kp 之前先把这个缩放系数定下来,否则 kp 的物理意义就乱了。中值 1500、上下限 900/2100 对应的是 0.9ms 到 2.1ms,超出这个范围舵机会顶到机械限位,长时间堵转烧舵机。

4.2 分段 PD:切换点要做滞回

分段系数的思路是把偏差分成近场和远场两段,近场用温柔系数保直道平稳,远场用激进系数保弯道灵敏。落地时最容易犯的错是切换点没有滞回:偏差在 25 附近上下浮动时,系数在两组值之间来回跳,舵机输出就会出现周期性的抖动。

工作区偏差范围kpkd适用场景
近场|err| < 22206直道、微调
过渡带22 ≤ |err| < 28保持上一次保持上一次防抖,不切换
远场|err| ≥ 284514入弯、急弯

过渡带这一行的意思是:进入某个区用的是门槛值(22 或 28),退出某个区用的是另一个门槛(22),中间这 6 个像素的宽度就是滞回带。实现上用一个静态变量记住当前处于哪个区,只有偏差越过对应的进出阈值时才改状态。

4.3 增量式 PID 速度环:只输出 PWM 增量

速度环的输入是编码器脉冲数,输出是电机 PWM。这里不用位置式 PID 而用增量式,主要原因有两个。位置式 PID 需要累加积分项,一旦目标速度从 2300 切到 1500(入弯降速),累加出来的积分项还在高位,PWM 要过好几十毫秒才能降下来,表现出来就是入弯刹不住。增量式 PID 每次只算 PWM 的增量,天然不存在积分饱和,切目标速度时输出立刻跟着走。

#define TARGET_STRAIGHT 2300 // 编码器脉冲数/10ms, 对应约 2.3 m/s #define TARGET_CURVE 1500 // 弯道目标速度 /* 增量式 PID, 返回本次电机 PWM 值 */ int16_t speed_inc(int16_t target, int16_t actual) { static int16_t e0 = 0, e1 = 0, e2 = 0; static int16_t pwm = 0; int16_t delta; e2 = e1; e1 = e0; // 偏差序列后移 e0 = target - actual; // 本次速度偏差 delta = 12 * (e0 - e1) // Kp: 比例增量 + 3 * e0 // Ki: 积分项, 消除稳态误差 + 1 * (e0 - 2 * e1 + e2); // Kd: 微分项, 抑制超调 pwm += delta; if (pwm > 8000) pwm = 8000; // 占空比上限, 保护 MOS 和电机 if (pwm < 0) pwm = 0; // 不允许反向, 换向交给方向引脚 return pwm; }

三个系数的初始值我一般按 12/3/1 起步,先把 Kp 单独加上去到速度能跟上但不振荡,再加 Ki 把稳态误差压掉,最后用 Kd 削超调。Ki 是唯一能把速度真正拉到目标值的项,只有 Kp 的话稳态会差一个固定量,因为摩擦和风阻需要固定的 PWM 去抵消。目标速度的切换由舵机调节量预判:转向输出越偏离中值,说明越接近弯道,目标速度就越低,这样车会在入弯前就开始减速,而不是等偏差很大了才反应。

5. 实车调试:灰度波形、PWM 回读与分段验证

调车最耗时间的不是写代码,是分清一个抖动到底是图像问题、控制问题还是机械问题。有效的做法是把中间量全部吐出来看,而不是只盯着车跑得直不直。

5.1 把 128 点灰度值打到上位机

在固件里开一个串口通道,每帧把 128 个灰度值加一个帧头打出去,波特率 115200,占用时间可以接受。上位机用几十行 Python 就能画出实时波形。

import serial import matplotlib.pyplot as plt ser = serial.Serial('COM3', 115200, timeout=1) # 波特率与固件一致 while True: line = ser.readline().decode('ascii', 'ignore').strip() if not line.startswith('$PIX'): continue vals = [int(v) for v in line[4:].split(',')] # 解析 128 个灰度值 if len(vals) != 128: continue # 丢帧直接跳过 plt.cla() plt.plot(vals) plt.axhline(150, color='r', linestyle='--') # 与固件 WHITE_TH 保持一致 plt.ylim(0, 255) plt.pause(0.01)

axhline那条红线必须和固件里的阈值常量取同一个值,否则你看到的"边界位置"和车实际算出来的不是一回事。波形上有三类故障一眼能认出来:整行数值整体抬高或压低,是曝光时间漂了或者环境光变了,改EXPOSURE_US;某几个点单独冒尖,是地线干扰串进了 ADC 参考,去查电机地和模拟地的汇接点;黑线边沿在波形上是一条很宽的缓坡而不是陡降,说明阈值正好卡在过渡带上,把门限往上或往下挪十个 LSB 就干净了。

5.2 按顺序验证,别一步到位

上电顺序错了,出的问题会互相掩盖。我习惯分四步走:第一步车架起来,轮子不落地,看 128 点波形是否稳定,标定曝光和阈值;第二步手动推着车沿赛道走,看串口打印的err曲线是否和车的实际横向偏移同向、量级合理,这一步能验证边界提取和符号方向;第三步落地低速跑,目标速度设到 800 左右,只开转向环,确认车能沿线走但不追求速度;第四步再放速度,同时把速度环的 Ki 从小往大加。

验证转向极性有个不用跑车的办法:把车放在赛道中线偏左的位置,err应该是负的,舵机输出应该小于中值 1500。如果符号相反,问题要么在偏差计算,要么在舵机安装方向,先改软件符号,别急着拆机械,因为拆了之后图像这边的坐标系也跟着变,会陷进来回改的循环。

最后一章再给一个具体技巧:每次改完参数,把当次的kp/kdTARGET_STRAIGHTWHITE_TH三个值和当圈的实测数据一起记下来,写在一张纸上贴在调车桌边。参数不是孤立的最优解,它们是互相耦合的——提高目标速度往往要同步降低近场 kp,抬高阈值往往要同步缩短曝光。没有这份记录,改到第三天你就不知道自己昨天那套跑得不错的参数到底是什么了。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询