前言
前面兩篇文章講了.NET IoT相關(guān)的知識點(diǎn),以及硬件的GPIO的一些概念,還有點(diǎn)亮兩個(gè)屏幕的方法,這些讓大家對.NET的用途有了新的認(rèn)識,那我們這回繼續(xù)講解.NET IoT的知識點(diǎn),以及介紹一些好玩的東西,例如讓視頻通過機(jī)器人的屏幕播放起來,還有機(jī)器人的身體也能通過我們的代碼控制動起來。大家感興趣的話可以跟著我的文章繼續(xù)下去,另外說下我B站更新了機(jī)器人相關(guān)視頻,所以大家可以跟著觀看制作,視頻包含了機(jī)器人的組裝和打印文件使用,點(diǎn)擊圖片即可跳轉(zhuǎn)。
![](/files/attmgn/2025/2/freeflydom20250206102543259_0.jpg)
問題解答
大家看完這篇文章,大概對機(jī)器人的一些功能模塊有了了解,大家肯定會有疑問,做這個(gè)機(jī)器人到底需要什么電路板,以及只用樹莓派到底能夠做到什么程度,我會挑一些大家可能會問的問題做一些解答。
1. 只用樹莓派可以控制舵機(jī)嗎?
只用樹莓派控制舵機(jī)是OK,舵機(jī)本身是使用PWM的信號進(jìn)行控制的,這個(gè)可以通過樹莓派的引腳進(jìn)行模擬,這個(gè)不在本文章的討論范圍內(nèi),有需要可以單獨(dú)寫一篇文章進(jìn)行講解。
2. 機(jī)器人的制作到底需要哪些電路板?
下圖為完整的硬件相關(guān)的部分,大家可以大概的了解到機(jī)器人的電路構(gòu)成。
![](/files/attmgn/2025/2/freeflydom20250206102543439_1.jpg)
目前機(jī)器人總共需要三塊板子,一塊是我設(shè)計(jì)的搭配樹莓派使用的,另外兩塊是使用的ElectronBot精英版A2的一個(gè)舵機(jī)驅(qū)動板(用來改裝舵機(jī)并且驅(qū)動舵機(jī)的運(yùn)動),一個(gè)語音板子(包含麥克風(fēng),喇叭,和攝像頭連接),這些大家都可以通過在閑魚之類搜索ElectronBot相關(guān)的關(guān)鍵詞買到,大家不要懼怕自己不會焊接電路板不能學(xué)習(xí)之類。即使大家買不到電路板,通過文章進(jìn)行學(xué)習(xí)也是問題不大的,所以大家不要擔(dān)心。
3. 如果想學(xué)習(xí)應(yīng)該怎么樣獲得電路板?
這個(gè)現(xiàn)在網(wǎng)絡(luò)上都有一站式創(chuàng)客電路板生產(chǎn)的平臺,例如嘉立創(chuàng)(這個(gè)非廣告因?yàn)檫@個(gè)是國內(nèi)算是很成熟的平臺了),我剛才提到的ElectronBot精英版A2和我的樹莓派拓展板子都在立創(chuàng)的開源廣場有提供,大家直接跟著下單就能夠拿到電路板了,然后就可以購買芯片物料焊接了。
4. ElectronBot和我做的機(jī)器人有什么關(guān)系?
ElectronBot是稚暉君(B站一個(gè)有名的UP主)制作的一個(gè)開源的必須連接電腦的桌面機(jī)器人,我和網(wǎng)友在他的方案基礎(chǔ)上優(yōu)化了電路板出了一個(gè)ElectronBot精英版A2的版本,現(xiàn)在我通過用樹莓派替換了ElectronBot的屏幕控制和舵機(jī)控制部分,實(shí)現(xiàn)了一個(gè)獨(dú)立的版本,我為了省事,就借用了ElectronBot的兩個(gè)電路板,省的自己設(shè)計(jì)了。
名詞解釋
![](/files/attmgn/2025/2/freeflydom20250206102543559_2.jpg)
舵機(jī)是一種位置(角度)伺服的驅(qū)動器,適用于那些需要角度不斷變化并可以保持的控制系統(tǒng)。舵機(jī)通過一瞬間的堵轉(zhuǎn)扭力將舵盤進(jìn)行轉(zhuǎn)向,持續(xù)的時(shí)間短。最早以前在高檔遙控玩具,如飛機(jī)、潛艇模型,遙控機(jī)器人中已經(jīng)得到了普遍應(yīng)用。如今通過技術(shù)的革新,加工工藝的升級,舵機(jī)在各行各業(yè)中已得到越來越廣泛的應(yīng)用。
I2C(Inter-Integrated Circuit),讀作:I方C,是一種同步、多主多從架構(gòu)、雙向雙線的串行通信總線,通常應(yīng)用于短距離、低速通信場景,廣泛用于微控制器和各種外圍設(shè)備之間的通信。它使用兩條線路:串行數(shù)據(jù)線(SDA)和串行時(shí)鐘線(SCL)進(jìn)行雙向傳輸。
Lottie 是一種輕量級的基于 JSON 的動畫格式,可以在任何設(shè)備或?yàn)g覽器上播放。設(shè)計(jì)師和開發(fā)人員廣泛使用它來改善網(wǎng)站和應(yīng)用程序的交互。Lottie 的矢量結(jié)構(gòu)允許用戶在不失去圖像質(zhì)量或增加文件大小的情況下縮放動畫。
FFmpeg 是一個(gè)完整的跨平臺音視頻解決方案,用于記錄、轉(zhuǎn)換和流式處理音視頻。它是目前最強(qiáng)大的音視頻處理開源軟件之一,被廣泛應(yīng)用于視頻網(wǎng)站、播放器、編碼器等多種場景中。
舵機(jī)控制
舵機(jī)控制板固件相關(guān)介紹
- 首先我們象征性的看下舵機(jī)板子的固件代碼,舵機(jī)控制板使用STM32F103標(biāo)準(zhǔn)庫硬件IIC+DMA的類似方案進(jìn)行數(shù)據(jù)讀寫,有社區(qū)的人進(jìn)行了優(yōu)化,但是核心代碼大體相同,改裝的舵機(jī)板比舵機(jī)原始的只支持角度控制有更多的玩法。參考如下文檔:
STM32F103標(biāo)準(zhǔn)庫硬件IIC+DMA連續(xù)數(shù)據(jù)發(fā)送、接收
,核心代碼如下:
void I2C_SlaveDMARxCpltCallback()
{
ErrorStatus state;
float valF = *((float*) (i2cDataRx + 1));
i2cDataTx[0] = i2cDataRx[0];
switch (i2cDataRx[0])
{
case 0x01:
{
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:
{
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:
{
motor.SetTorqueLimit(valF);
auto* b = (unsigned char*) &(motor.angle);
for (int i = 0; i < 4; i++)
i2cDataTx[i + 1] = *(b + i);
break;
}
case 0x11:
{
auto* b = (unsigned char*) &(motor.angle);
for (int i = 0; i < 4; i++)
i2cDataTx[i + 1] = *(b + i);
break;
}
case 0x12:
{
auto* b = (unsigned char*) &(motor.velocity);
for (int i = 0; i < 4; i++)
i2cDataTx[i + 1] = *(b + i);
break;
}
case 0x21:
{
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:
{
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:
{
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:
{
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:
{
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:
{
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:
{
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);
}
}
void TIM14_PeriodElapsedCallback()
{
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);
motor.CalcDceOutput(motor.angle, 0);
motor.SetPwm((int16_t) motor.dce.output);
}
- 固件控制指令對照圖,這些指令是通過樹莓派I2C引腳進(jìn)行發(fā)送。
![](/files/attmgn/2025/2/freeflydom20250206102543582_3.jpg)
- 個(gè)人的一些心得,控制板核心邏輯有個(gè)死循環(huán),如果通訊不正常,會一直等待,所以如果樹莓派的執(zhí)行控制代碼發(fā)送的不對,會出現(xiàn)I2C引腳超時(shí)的錯(cuò)誤,這個(gè)大家操作的時(shí)候一定要記住接線是否正確,代碼是否配置OK。
- I2C設(shè)備都是并聯(lián)到I2C總線上的,每個(gè)設(shè)備都有一個(gè)設(shè)備的ID,所以我們在和設(shè)備通訊的時(shí)候一定要指定設(shè)備的ID才能完成初始化。
![](/files/attmgn/2025/2/freeflydom20250206102543602_4.jpg)
舵機(jī)控制代碼編寫
由于我做的獨(dú)立版桌面機(jī)器人目前只用到了兩個(gè)舵機(jī),所以我選擇了2號和3號ID的舵機(jī)進(jìn)行控制。通過初始化I2C設(shè)備對象,進(jìn)行通訊的建立,并進(jìn)行角度的控制。示例代碼是將舵機(jī)循環(huán)往復(fù)的運(yùn)動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 設(shè)備連接成功--{DateTime.Now.ToString("s")}");
foreach (var data in receiveData)
{
Console.Write($"{data}, ");
}
}
}
catch (Exception ex)
{
Console.WriteLine($"I2C 設(shè)備連接失敗: {ex.Message}");
}
Console.ReadLine();
控制代碼看起來很簡單,但是這里有個(gè)坑,就是大家也看到了一個(gè)奇怪的地方,就是為什么發(fā)送數(shù)據(jù)的時(shí)候要用WriteRead這個(gè)方法,而不是先write再Read這樣的操作。其實(shí)這里也卡住我了,我翻了固件的源碼,我懷疑是因?yàn)槎鏅C(jī)版子的速度太快了,導(dǎo)致讀寫的區(qū)分不大,如果我只是寫入數(shù)據(jù)再讀取會導(dǎo)致循環(huán)卡住,這里我是推測,我翻了.NET IoT的這個(gè)I2C通訊的源碼,然后我用了WriteRead這個(gè)方法測試,發(fā)現(xiàn)通訊是OK的,如果有大佬能給出更詳細(xì)的解答,歡迎評論區(qū)給大家科普一下。到這里舵機(jī)的控制就算是完成了,具體更詳細(xì)的控制大家可以根據(jù)控制指令手冊進(jìn)行編寫測試。
舵機(jī)測試
下圖標(biāo)出了樹莓派的I2C引腳位置,這兩個(gè)引腳和舵機(jī)控制板的I2C引腳進(jìn)行接線就可以通訊了,舵機(jī)板子需要供電,而且舵機(jī)板子的地線要和樹莓派板子共地,如果是其他的I2C設(shè)備也是一樣,例如陀螺儀,I2C屏幕。
![](/files/attmgn/2025/2/freeflydom20250206102543625_5.jpg)
如果接線OK,代碼運(yùn)行OK,正常情況下會看到舵機(jī)旋轉(zhuǎn)的樣子。
![](/files/attmgn/2025/2/freeflydom20250206102543647_6.jpg)
看到這里大家有什么疑問可以在評論區(qū)討論。
多種方式播放表情
這篇文章的篇幅有點(diǎn)長,上面我們講了舵機(jī)的控制,上一篇文章我們調(diào)通了屏幕的顯示,但是只顯示圖片其實(shí)不夠生動的,如果我們能夠配上表情的播放那就生動多了。
解析lottie動畫文件進(jìn)行播放
上面的名詞解釋我們解釋了什么是lottie動畫,那我們就直接看代碼吧,這個(gè)lottie動畫目前我在樹莓派上進(jìn)行解析不是很流暢,所以只是作為知識講解,大家如果是樹莓派4或者5應(yīng)該性能很好,解析起來應(yīng)該不費(fèi)勁,而且如果代碼能夠優(yōu)化一些應(yīng)該也可以流暢。
我的做法是通過使用一些解析庫,能夠解析lottie動畫,提取出幀數(shù)據(jù),然后解析成ImageSharp的Image類,然后轉(zhuǎn)換成字節(jié)數(shù)組就可以進(jìn)行播放了。下面是我找到的社區(qū)的一些開源庫,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>
核心的解析動畫并轉(zhuǎn)成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)
{
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));
using var image = SKImage.FromBitmap(bitmap);
using var data = image.Encode(SKEncodedImageFormat.Png, 100);
var bytes = data.ToArray();
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);
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"));
}
}
}
}
轉(zhuǎn)成Image對象之后,就可以使用我們上一篇文章里的方法轉(zhuǎn)成字節(jié)數(shù)組寫入到屏幕了。這個(gè)大家有興趣可以查看我的項(xiàng)目代碼里,有做demo測試。
桌面桌面機(jī)器人倉庫地址
通過轉(zhuǎn)換MP4格式文件進(jìn)行播放
這一種方式我是事先通過ffmpeg解析mp4的表情文件,然后將表情轉(zhuǎn)換成屏幕直接顯示的字節(jié)數(shù)組,并且序列化到j(luò)son文件里,這樣將解析轉(zhuǎn)換的部分的邏輯前置處理了,樹莓派在播放表情的時(shí)候就可以很輕松了。
核心轉(zhuǎn)換代碼邏輯如下:
將視頻幀轉(zhuǎn)成圖片的字節(jié)數(shù)組代碼:
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");
var mediaFile = new InputFile(filePath);
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;
}
}
將圖片字節(jié)數(shù)組轉(zhuǎn)成顯示屏需要的字節(jié)數(shù)組數(shù)據(jù)的代碼如下:
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))
{
image.Mutate(x => x.Resize(240, 240));
using (var background = new Image<Bgra32>(320, 240, new Bgra32(0, 0, 0)))
{
var x = (background.Width - image.Width) / 2;
var y = (background.Height - image.Height) / 2;
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;
}
}
}
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;
}
}
主程序序列化表情到j(luò)son數(shù)據(jù)的代碼如下:
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 imageProcessor = serviceProvider.GetRequiredService<ImageProcessor>();
var videoFilePath = "anger.mp4";
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);
}
await File.WriteAllTextAsync($"{data.Name}.json", JsonSerializer.Serialize(data));
var deserializedData = JsonSerializer.Deserialize<FrameMetaData>(await File.ReadAllTextAsync($"{data.Name}.json"));
Console.WriteLine($"Name: {deserializedData?.Name}, Width: {deserializedData?.Width}, Height: {deserializedData?.Height}");
Console.WriteLine("Frame extraction and processing completed. Metadata saved to frame_metadata.json.");
通過上面的代碼就可以制作出一個(gè)表情文件了,源代碼在我另外的倉庫里,然后通過桌面機(jī)器人倉庫的代碼反序列化并且播放就好了。效果如下:
![](/files/attmgn/2025/2/freeflydom20250206102544284_7.jpg)
總結(jié)感悟
轉(zhuǎn)自https://www.cnblogs.com/GreenShade/p/18692804
該文章在 2025/2/6 10:26:44 編輯過