前言
前面两篇文章讲了.NET IoT相关的知识点,以及硬件的GPIO的一些概念,还有点亮两个屏幕的方法,这些让大家对.NET的用途有了新的认识,那我们这回继续讲解.NET IoT的知识点,以及介绍一些好玩的东西,例如让视频通过机器人的屏幕播放起来,还有机器人的身体也能通过我们的代码控制动起来。大家感兴趣的话可以跟着我的文章继续下去,另外说下我B站更新了机器人相关视频,所以大家可以跟着观看制作,视频包含了机器人的组装和打印文件使用,点击图片即可跳转。
问题解答
大家看完这篇文章,大概对机器人的一些功能模块有了了解,大家肯定会有疑问,做这个机器人到底需要什么电路板,以及只用树莓派到底能够做到什么程度,我会挑一些大家可能会问的问题做一些解答。
1. 只用树莓派可以控制舵机吗?
只用树莓派控制舵机是OK,舵机本身是使用PWM的信号进行控制的,这个可以通过树莓派的引脚进行模拟,这个不在本文章的讨论范围内,有需要可以单独写一篇文章进行讲解。
2. 机器人的制作到底需要哪些电路板?
下图为完整的硬件相关的部分,大家可以大概的了解到机器人的电路构成。
目前机器人总共需要三块板子,一块是我设计的搭配树莓派使用的,另外两块是使用的的一个舵机驱动板(用来改装舵机并且驱动舵机的运动),一个语音板子(包含麦克风,喇叭,和摄像头连接),这些大家都可以通过在闲鱼之类搜索ElectronBot相关的关键词买到,大家不要惧怕自己不会焊接电路板不能学习之类。即使大家买不到电路板,通过文章进行学习也是问题不大的,所以大家不要担心。
3. 如果想学习应该怎么样获得电路板?
这个现在网络上都有一站式创客电路板生产的平台,例如嘉立创(这个非广告因为这个是国内算是很成熟的平台了),我刚才提到的和我的都在立创的开源广场有提供,大家直接跟着下单就能够拿到电路板了,然后就可以购买芯片物料焊接了。
4. ElectronBot和我做的机器人有什么关系?
ElectronBot是稚晖君(B站一个有名的UP主)制作的一个开源的必须连接电脑的桌面机器人,我和网友在他的方案基础上优化了电路板出了一个的版本,现在我通过用树莓派替换了ElectronBot的屏幕控制和舵机控制部分,实现了一个独立的版本,我为了省事,就借用了ElectronBot的两个电路板,省的自己设计了。
名词解释
1.
2.
I2C(Inter-Integrated Circuit),读作:I方C,是一种同步、多主多从架构、双向双线的串行通信总线,通常应用于短距离、低速通信场景,广泛用于微控制器和各种外围设备之间的通信。它使用两条线路:串行数据线(SDA)和串行时钟线(SCL)进行双向传输。
3.
Lottie 是一种轻量级的基于 JSON 的动画格式,可以在任何设备或浏览器上播放。设计师和开发人员广泛使用它来改善网站和应用程序的交互。Lottie 的矢量结构允许用户在不失去图像质量或增加文件大小的情况下缩放动画。
4.
FFmpeg 是一个完整的跨平台音视频解决方案,用于记录、转换和流式处理音视频。它是目前最强大的音视频处理开源软件之一,被广泛应用于视频网站、播放器、编码器等多种场景中。
舵机控制
舵机控制板固件相关介绍
- 首先我们象征性的看下舵机板子的固件代码,舵机控制板使用STM32F103标准库硬件IIC+DMA的类似方案进行数据读写,有社区的人进行了优化,但是核心代码大体相同,改装的舵机板比舵机原始的只支持角度控制有更多的玩法。参考如下文档:
,核心代码如下:// // Command handler void I2C_SlaveDMARxCpltCallback() { ErrorStatus state; float valF = *((float*) (i2cDataRx + 1)); i2cDataTx[0] = i2cDataRx[0]; switch (i2cDataRx[0]) { case 0x01: // Set angle { motor.dce.setPointPos = valF; auto* b = (unsigned char*) &(motor.angle); for (int i = 0; i < 4; i++) i2cDataTx[i + 1] = *(b + i); break; } case 0x02: // Set velocity { motor.dce.setPointVel = valF; auto* b = (unsigned char*) &(motor.velocity); for (int i = 0; i < 4; i++) i2cDataTx[i + 1] = *(b + i); break; } case 0x03: // Set torque { motor.SetTorqueLimit(valF); auto* b = (unsigned char*) &(motor.angle); for (int i = 0; i < 4; i++) i2cDataTx[i + 1] = *(b + i); break; } case 0x11: // Get angle { auto* b = (unsigned char*) &(motor.angle); for (int i = 0; i < 4; i++) i2cDataTx[i + 1] = *(b + i); break; } case 0x12: // Get velocity { auto* b = (unsigned char*) &(motor.velocity); for (int i = 0; i < 4; i++) i2cDataTx[i + 1] = *(b + i); break; } case 0x21: // Set id { boardConfig.nodeId = i2cDataRx[1]; boardConfig.configStatus = CONFIG_COMMIT; auto* b = (unsigned char*) &(motor.angle); for (int i = 0; i < 4; i++) i2cDataTx[i + 1] = *(b + i); break; } case 0x22: // Set kp { motor.dce.kp = valF; boardConfig.dceKp = valF; boardConfig.configStatus = CONFIG_COMMIT; auto* b = (unsigned char*) &(motor.angle); for (int i = 0; i < 4; i++) i2cDataTx[i + 1] = *(b + i); break; } case 0x23: // Set ki { motor.dce.ki = valF; boardConfig.dceKi = valF; boardConfig.configStatus = CONFIG_COMMIT; auto* b = (unsigned char*) &(motor.angle); for (int i = 0; i < 4; i++) i2cDataTx[i + 1] = *(b + i); break; } case 0x24: // Set kv { motor.dce.kv = valF; boardConfig.dceKv = valF; boardConfig.configStatus = CONFIG_COMMIT; auto* b = (unsigned char*) &(motor.angle); for (int i = 0; i < 4; i++) i2cDataTx[i + 1] = *(b + i); break; } case 0x25: // Set kd { motor.dce.kd = valF; boardConfig.dceKd = valF; boardConfig.configStatus = CONFIG_COMMIT; auto* b = (unsigned char*) &(motor.angle); for (int i = 0; i < 4; i++) i2cDataTx[i + 1] = *(b + i); break; } case 0x26: // Set torque limit { motor.SetTorqueLimit(valF); boardConfig.toqueLimit = valF; boardConfig.configStatus = CONFIG_COMMIT; auto* b = (unsigned char*) &(motor.angle); for (int i = 0; i < 4; i++) i2cDataTx[i + 1] = *(b + i); break; } case 0x27: // Set init pos { boardConfig.initPos = valF; boardConfig.configStatus = CONFIG_COMMIT; auto* b = (unsigned char*) &(motor.angle); for (int i = 0; i < 4; i++) i2cDataTx[i + 1] = *(b + i); break; } case 0xff: motor.SetEnable(i2cDataRx[1] != 0); break; default: break; } do { state = Slave_Transmit(i2cDataTx,5,5000); } while (state != SUCCESS); if(i2cDataRx[0] == 0x21) { Set_ID(boardConfig.nodeId); } } // Control loop void TIM14_PeriodElapsedCallback() { // Read sensor data LL_DMA_EnableChannel(DMA1,LL_DMA_CHANNEL_1); LL_ADC_REG_StartConversion(ADC1); motor.angle = motor.mechanicalAngleMin + (motor.mechanicalAngleMax - motor.mechanicalAngleMin) * ((float) adcData[0] - (float) motor.adcValAtAngleMin) / ((float) motor.adcValAtAngleMax - (float) motor.adcValAtAngleMin); // Calculate PID motor.CalcDceOutput(motor.angle, 0); motor.SetPwm((int16_t) motor.dce.output); }
- 固件控制指令对照图,这些指令是通过树莓派I2C引脚进行发送。
- 个人的一些心得,控制板核心逻辑有个死循环,如果通讯不正常,会一直等待,所以如果树莓派的执行控制代码发送的不对,会出现I2C引脚超时的错误,这个大家操作的时候一定要记住接线是否正确,代码是否配置OK。
- I2C设备都是并联到I2C总线上的,每个设备都有一个设备的ID,所以我们在和设备通讯的时候一定要指定设备的ID才能完成初始化。
舵机控制代码编写
由于我做的独立版桌面机器人目前只用到了两个舵机,所以我选择了2号和3号ID的舵机进行控制。通过初始化I2C设备对象,进行通讯的建立,并进行角度的控制。示例代码是将舵机循环往复的运动180°,使用.NET IoT库编写,并在树莓派上部署使用,示例代码如下:
using System.Device.I2c;
try
{
while (true)
{
using I2cDevice i2c = I2cDevice.Create(new I2cConnectionSettings(1, 0x02));
using I2cDevice i2c8 = I2cDevice.Create(new I2cConnectionSettings(1, 0x03));
byte[] writeBuffer = new byte[5] { 0xff, 0x01, 0x00, 0x00, 0x00 };
byte[] receiveData = new byte[5];
i2c.WriteRead(writeBuffer, receiveData);
i2c8.WriteRead(writeBuffer, receiveData);
for (int i = 0; i < 180; i += 1)
{
float angle = i;
byte[] angleBytes = BitConverter.GetBytes(angle);
writeBuffer[0] = 0x01;
Array.Copy(angleBytes, 0, writeBuffer, 1, angleBytes.Length);
i2c.WriteRead(writeBuffer, receiveData);
i2c8.WriteRead(writeBuffer, receiveData);
Thread.Sleep(20);
}
for (int i = 180; i > 0; i -= 1)
{
float angle = i;
byte[] angleBytes = BitConverter.GetBytes(angle);
writeBuffer[0] = 0x01;
Array.Copy(angleBytes, 0, writeBuffer, 1, angleBytes.Length);
i2c.WriteRead(writeBuffer, receiveData);
i2c8.WriteRead(writeBuffer, receiveData);
Thread.Sleep(20);
}
Console.WriteLine($"I2C 2 8 设备连接成功--{DateTime.Now.ToString("s")}");
foreach (var data in receiveData)
{
Console.Write($"{data}, ");
}
//Console.WriteLine();
//Thread.Sleep(500);
}
}
catch (Exception ex)
{
Console.WriteLine($"I2C 设备连接失败: {ex.Message}");
}
Console.ReadLine();
控制代码看起来很简单,但是这里有个坑,就是大家也看到了一个奇怪的地方,就是为什么发送数据的时候要用WriteRead这个方法,而不是先write再Read这样的操作。其实这里也卡住我了,我翻了固件的源码,我怀疑是因为舵机版子的速度太快了,导致读写的区分不大,如果我只是写入数据再读取会导致循环卡住,这里我是推测,我翻了.NET IoT的这个I2C通讯的源码,然后我用了WriteRead这个方法测试,发现通讯是OK的,如果有大佬能给出更详细的解答,欢迎评论区给大家科普一下。到这里舵机的控制就算是完成了,具体更详细的控制大家可以根据控制指令手册进行编写测试。
舵机测试
下图标出了树莓派的I2C引脚位置,这两个引脚和舵机控制板的I2C引脚进行接线就可以通讯了,舵机板子需要供电,而且舵机板子的地线要和树莓派板子共地,如果是其他的I2C设备也是一样,例如陀螺仪,I2C屏幕。
如果接线OK,代码运行OK,正常情况下会看到舵机旋转的样子。
看到这里大家有什么疑问可以在评论区讨论。
多种方式播放表情
这篇文章的篇幅有点长,上面我们讲了舵机的控制,上一篇文章我们调通了屏幕的显示,但是只显示图片其实不够生动的,如果我们能够配上表情的播放那就生动多了。
解析lottie动画文件进行播放
上面的名词解释我们解释了什么是lottie动画,那我们就直接看代码吧,这个lottie动画目前我在树莓派上进行解析不是很流畅,所以只是作为知识讲解,大家如果是树莓派4或者5应该性能很好,解析起来应该不费劲,而且如果代码能够优化一些应该也可以流畅。
我的做法是通过使用一些解析库,能够解析lottie动画,提取出帧数据,然后解析成ImageSharp的Image类,然后转换成字节数组就可以进行播放了。下面是我找到的社区的一些开源库,SkiaSharp.Skottie有提供解析功能。
<ItemGroup>
<PackageReference Include="SkiaSharp" Version="3.116.1" />
<PackageReference Include="SkiaSharp.Skottie" Version="3.116.1" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.6" />
</ItemGroup>
核心的解析动画并转成Image的代码如下:
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
using SkiaSharp;
using SkiaSharp.Skottie;
namespace Verdure.LottieToImage;
public class LottieToImage
{
public static Image<Bgra32> RenderLottieFrame(Animation animation, double progress, int width, int height)
{
// 创建SKSurface用于渲染
using var bitmap = new SKBitmap(width, height);
using var canvas = new SKCanvas(bitmap);
// 清除背景
canvas.Clear(SKColors.Transparent);
animation.SeekFrameTime(progress);
animation.Render(canvas, new SKRect(0, 0, width, height));
// 将SKBitmap转换为byte数组
using var image = SKImage.FromBitmap(bitmap);
using var data = image.Encode(SKEncodedImageFormat.Png, 100);
var bytes = data.ToArray();
// 转换为ImageSharp格式
using var memStream = new MemoryStream(bytes);
return Image.Load<Bgra32>(memStream);
}
public static async Task SaveLottieFramesAsync(string lottieJsonPath, string outputDir, int width, int height)
{
Directory.CreateDirectory(outputDir);
// 读取Lottie JSON文件
var animation = Animation.Create(lottieJsonPath);
if (animation != null)
{
//帧数
var frameCount = animation.OutPoint;
for (int i = 0; i < frameCount; i++)
{
var progress = animation.Duration.TotalSeconds / (frameCount - i);
var frame = RenderLottieFrame(animation, progress, width, height);
await frame.SaveAsPngAsync(Path.Combine(outputDir, $"frame_{i:D4}.png"));
}
}
}
}
转成Image对象之后,就可以使用我们上一篇文章里的方法转成字节数组写入到屏幕了。这个大家有兴趣可以查看我的项目代码里,有做demo测试。
通过转换MP4格式文件进行播放
这一种方式我是事先通过ffmpeg解析mp4的表情文件,然后将表情转换成屏幕直接显示的字节数组,并且序列化到json文件里,这样将解析转换的部分的逻辑前置处理了,树莓派在播放表情的时候就可以很轻松了。
核心转换代码逻辑如下:
将视频帧转成图片的字节数组代码:
using FFmpeg.NET;
using FFmpegImageSharp.Models;
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
namespace FFmpegImageSharp.Services;
public class FrameExtractor
{
public async Task<List<FrameData>> ExtractFramesAsync(string filePath)
{
var frames = new List<FrameData>();
var ffmpeg = new Engine("C:\\ffmpeg-n7.1-latest-win64-gpl-7.1\\ffmpeg-n7.1-latest-win64-gpl-7.1\\bin\\ffmpeg.exe"); // Specify the path to ffmpeg executable
var mediaFile = new InputFile(filePath); // Use a concrete class instead of MediaFile
var mediaInfo = await ffmpeg.GetMetaDataAsync(mediaFile, CancellationToken.None);
var duration = mediaInfo.Duration;
var frameRate = mediaInfo.VideoData.Fps;
var frameCount = (int)(duration.TotalSeconds * frameRate);
for (var i = 0; i < frameCount; i++)
{
var timestamp = TimeSpan.FromSeconds(i / frameRate);
var outputFilePath = $"frame_{i}.jpg";
var arguments = $"-i \"{filePath}\" -vf \"select='eq(n\\,{i})'\" -vsync vfr -q:v 2 \"{outputFilePath}\"";
await ffmpeg.ExecuteAsync(arguments, CancellationToken.None);
var frameImage = await File.ReadAllBytesAsync(outputFilePath);
var frameData = new FrameData
{
ImageData = frameImage,
Timestamp = timestamp
};
frames.Add(frameData);
}
return frames;
}
}
将图片字节数组转成显示屏需要的字节数组数据的代码如下:
using FFmpegImageSharp.Models;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
using SixLabors.ImageSharp.Processing;
namespace FFmpegImageSharp.Services;
public class ImageProcessor
{
public byte[] ProcessImage(FrameData frame)
{
using (var image = Image.Load(frame.ImageData))
{
// Resize the image to 240x240
image.Mutate(x => x.Resize(240, 240));
// Create a new 320x240 image with a custom background color
using (var background = new Image<Bgra32>(320, 240, new Bgra32(0, 0, 0))) // Custom color: black
{
// Calculate the position to center the 240x240 image on the 320x240 background
var x = (background.Width - image.Width) / 2;
var y = (background.Height - image.Height) / 2;
// Draw the resized image onto the background
background.Mutate(ctx => ctx.DrawImage(image, new Point(x, y), 1f));
background.Mutate(x => x.Rotate(90));
using Image<Bgr24> converted2inch4Image = background.CloneAs<Bgr24>();
var byteList = GetImageBytes(converted2inch4Image);
return byteList;
// Save the processed image or perform further processing
//background.Save($"path_to_save_processed_image_{DateTime.Now.Ticks}.png");
}
}
}
public byte[] GetImageBytes(Image<Bgr24> image, int xStart = 0, int yStart = 0)
{
int imwidth = image.Width;
int imheight = image.Height;
var pix = new byte[imheight * imwidth * 2];
for (int y = 0; y < imheight; y++)
{
for (int x = 0; x < imwidth; x++)
{
var color = image[x, y];
pix[(y * imwidth + x) * 2] = (byte)((color.R & 0xF8) | (color.G >> 5));
pix[(y * imwidth + x) * 2 + 1] = (byte)(((color.G << 3) & 0xE0) | (color.B >> 3));
}
}
return pix;
}
}
主程序序列化表情到json数据的代码如下:
using System.Text.Json;
using FFmpegImageSharp.Models;
using FFmpegImageSharp.Services;
using Microsoft.Extensions.DependencyInjection;
var serviceProvider = new ServiceCollection()
.AddSingleton<FrameExtractor>()
.AddSingleton<StreamFrameExtractor>()
.AddSingleton<ImageProcessor>()
.BuildServiceProvider();
var frameExtractor = serviceProvider.GetRequiredService<FrameExtractor>();
//var streamFrameExtractor = serviceProvider.GetRequiredService<StreamFrameExtractor>();
var imageProcessor = serviceProvider.GetRequiredService<ImageProcessor>();
var videoFilePath = "anger.mp4"; // Update with your video file path
var data = new FrameMetaData
{
Name = Path.GetFileNameWithoutExtension(videoFilePath),
FileName = videoFilePath,
Width = 240,
Height = 320
};
var frames = await frameExtractor.ExtractFramesAsync(videoFilePath);
foreach (var frame in frames)
{
var list = imageProcessor.ProcessImage(frame);
data.FrameDatas.Add(list);
}
// JSON serialization
await File.WriteAllTextAsync($"{data.Name}.json", JsonSerializer.Serialize(data));
// JSON deserialization
var deserializedData = JsonSerializer.Deserialize<FrameMetaData>(await File.ReadAllTextAsync($"{data.Name}.json"));
// Verify deserialization
Console.WriteLine($"Name: {deserializedData?.Name}, Width: {deserializedData?.Width}, Height: {deserializedData?.Height}");
Console.WriteLine("Frame extraction and processing completed. Metadata saved to frame_metadata.json.");
通过上面的代码就可以制作出一个表情文件了,在我另外的仓库里,然后通过的代码反序列化并且播放就好了。效果如下:
总结感悟
这篇文章介绍了两个方面的东西,内容也算是比较长了,个人感悟,有的时候方案自己感觉挺好的,但是实施的时候有的时候效果不是很理想,感觉我们有时候要多准备几个方案,当验证完其中一个不行的时候也可以及时的切换方案,这样也不至于让我们着急了。当然如果有大佬能够直接想出完美的方案,那就最好不过了。
最近国产的大语言模型deepseek公司都给美国带来了不小的压力,目测2025年大语言模型应该会有很大的进步了,希望能够对我们的生活的各个方面带来质的改变吧,我们无法选择生命的长度,却可以主宰生命的宽度与高度。希望大家新年都能有新的进步。