ربات تحویلکار خودران با نقشهبرداری SLAM روی رزبریپای ۵
اگه تا حالا به فکر ساخت یه ربات بودی که خودش تو خونه یا محیط بسته مسیرش رو پیدا کنه، بدون اینکه به ریموتکنترل یا کنترل دستی نیاز داشته باشه، این پروژه دقیقا همونیه که دنبالش بودی. تو این آموزش قراره یک ربات زمینی خودران بسازیم که با کمک لیزر اسکنر و یک الگوریتم هوشمند، نقشهٔ محیط اطرافش رو خودش ترسیم میکنه و مسیر بهینه رو برای رسیدن به مقصد پیدا میکنه.
چیزی که این پروژه رو جذاب میکنه اینه که همهٔ پردازشها، از فیوژن سنسورها گرفته تا نقشهبرداری SLAM و مسیریابی، مستقیم روی خود رزبریپای 5 انجام میشه؛ بدون نیاز به میکروکنترلر واسط. یعنی یک مغز واحد که هم دادههای موتور و ژیروسکوپ رو میخونه، هم نقشهٔ محیط رو میسازه و هم مسیر حرکت رو کنترل میکنه.
تو این پروژه از تیم آیسیمدار قراره قدم به قدم با هم یک شاسی سهطبقه بسازیم، سیستم برق دوخطه و ایزوله راه بندازیم، سنسور لیدار و IMU رو سیمکشی کنیم و در نهایت یک اسکریپت پایتون بنویسیم که همهٔ اینها رو به یک ربات تحویلکار واقعی و خودران تبدیل میکنه. خب دیگه بریم سراغ طراحی و ساخت این ربات.
قطعات موردنیاز
- رزبریپای 5 (نسخهٔ 8 گیگابایت رم)
- لیدار اسکنر لیزری دو بعدی RPLidar A1 (به همراه برد و کابل تبدیل USB به UART)
- ماژول سنسور IMU شش محوره MPU6050
- 2 عدد موتور گیربکسی DC دارای انکودر مدل JGA25-370 (12 ولت، 1360 دور در دقیقه با انکودر افکت هال داخلی)
- 2 عدد چرخ با فریم فلزی یا پلاستیک سخت و رویهٔ لاستیکی (شعاع 0.032 متر)
- ماژول درایور موتور دو H-بریج مدل ZK-5AD
- مبدل باک کاهنده ولتاژ 5 ولت / 5 آمپر با بازدهی بالا (دارای خروجی USB-C)
- ماسفت قدرت کانال N (مثل IRF540N یا معادل با گیت لاجیکلول)
- شاسی چرخدار پایه برای اسباببازی به عنوان پلتفرم پایه
- 8 عدد سلول باتری لیتیوم-یون 18650 (تشکیل دو پک مستقل 5S و 3S)
- 2 عدد کلید فشاری تحمل جریان بالا (برای جداسازی مستقل هر خط تغذیه)
- 2 عدد خازن الکترولیتی 100 میکروفاراد (برای حذف نویز سوییچینگ)
- الایدی قرمز و 1 عدد الایدی سبز (نشانگرهای وضعیت)
- 2 عدد مقاومت 330 اهم (مقاومت محدودکنندهٔ جریان برای الایدیها)
- سیمهای رابط تکرشته و چندرشته
- پیچهای ساختاری بلند، مهرهٔ متناسب، واشر و استندآفهای نایلونی برد
- کارت حافظهٔ MicroSD (حداقل 32 گیگابایت) با سیستمعامل Raspberry Pi OS نسخهٔ 64 بیتی Bookworm
- هویه و سیم لحیم
- مولتیمتر (برای بررسی ولتاژ خروجی مبدل باک و تست اتصالات)
- نرمافزار Tiger VNC Viewer و کلاینت SSH (نصبشده روی لپتاپ برای تنظیم بدون مانیتور)
چیدمان مکانیکی و لایهبندی شاسی سهطبقه
برای اینکه ربات بتونه راحت از راهروهای باریک داخل ساختمون رد بشه و در عین حال هم نقشهبرداری خودران انجام بده و هم بار حمل کنه، شاسی این ربات رو به شکل سه طبقهٔ عمودی طراحی کردیم که یک محفظهٔ بار هم بالای همهٔ این طبقات نصب شده. چیدن قطعات به صورت عمودی باعث میشه هم عرض ربات کم بشه و هم مرکز ثقلش متعادل بمونه.
بذار دقیقتر بگیم که هر قطعه دقیقا کجای این طبقات جا میگیره، از پایینترین طبقه شروع میکنیم:
1. طبقهٔ اول شاسی: کنترل پایه و سیستم حرکتی
این طبقه پایهٔ فیزیکی ربات محسوب میشه، هم حرکت چرخها رو مدیریت میکنه و هم مغز اصلی پردازش روش سوار میشه.
- زیر شاسی: دو موتور گیربکسی JGA25-370 با گشتاور بالا محکم به کف این صفحه بسته میشن و چرخهای لاستیکی با شعاع دقیق 0.032 متر رو میچرخونن.
- یک چرخ گردان (کستور) هم وسط جلوی شاسی نصب میشه.
- روی شاسی: رزبریپای 5، مبدل باک 5 ولت / 5 آمپر و درایور موتور دو H-بریج ZK-5AD همگی مستقیم روی همین صفحه نصب میشن. ماژول IMU مدل MPU6050 هم با پیچ به این صفحه محکم میشه، دقیقا نزدیک خط مرکزی محور چرخها تا هنگام پیچهای تند دچار انحراف نشه.
2. طبقهٔ دوم شاسی: عرشهٔ انرژی
با استفاده از پیچهای ساختاری بلند 2 اینچی به عنوان فاصلهانداز عمودی، طبقهٔ دوم شاسی درست بالای طبقهٔ اول نصب میشه.
- قانون فاصله: وقتی این طبقه رو محکم میکنی، حتما مطمئن شو که کف طبقهٔ دوم کاملا از قطعات و سیمکشی طبقهٔ اول فاصله داره و رویش فشار نمیآره.
- چیدمان روی طبقه: این طبقه کاملا به سلولهای باتری اختصاص داره. دو پک باتری جداگانه با مجموع هشت سلول 18650 لیتیوم-یون اینجا جا میگیرن: یک پک مستقل 5S برای تغذیهٔ تمیز بخش لاجیک و یک پک مستقل 3S برای تغذیهٔ پرجریان موتورها.
- دلیل طراحی شارژ خارجی: چون این ساخت از یک سیستم مدیریت باتری (BMS) سنگین صرفنظر کرده، باتریها باید بیرون از ربات شارژ بشن. برای همین یک فضای خالی درست بالای پکهای باتری گذاشتیم تا بتونی راحت سلولها رو از کلیپهای فنری بیرون بکشی، تو شارژر خارجی بذاری و دوباره جاشون بذاری، بدون اینکه کل بدنهٔ ربات رو باز کنی.
3. طبقهٔ سوم شاسی: عرشهٔ حسگرها
با همون پیچهای عمودی که تا اینجا استفاده کردیم، طبقهٔ سوم هم بالای عرشهٔ انرژی نصب میشه، طوری که کاملا از باتریهای 18650 فاصله داشته باشه.
- چیدمان روی طبقه: لیدار اسکنر دو بعدی RPLidar A1 دقیقا وسط این طبقه قرار میگیره. زیر لیدار سوراخهای پیچ اختصاصی داره که باهاشون مستقیم و محکم به این صفحهٔ سوم پیچ میشه.
4. طبقهٔ آخر: محفظهٔ بار معلق
برای اینکه این پلتفرم واقعا به یک وسیلهٔ تحویلکار تبدیل بشه، یک جعبهٔ بار روی نوک شاسی نصب میشه.
- قانون فاصله از لیدار چرخان: چون RPLidar A1 برای نقشهبرداری صفحهٔ 360 درجه یک سر لیزری چرخان داره، نباید هیچ وزنی مستقیم روی بدنهٔ سنسور بذاری. این کار موتور تسمهایاش رو گیر میندازه و میسوزونه.
- راهحل ساختاری: برای دور زدن این مشکل، جعبهٔ تحویل کاملا بالاتر از سنسور چرخان، روی چهار پیچ ساختاری بلند که در چهار گوشهٔ طبقهٔ سوم شاسی نصب شدن، معلق میمونه. جعبه مستقیم به همین ستونهای گوشه پیچ میشه. اینطوری وزن بار کاملا از سنسور جدا میمونه، همهٔ فشار از طریق فریم شاسی به چرخها منتقل میشه و لیدار هم زیرش آزادانه میچرخه!
💡 نکتهٔ طراحی: افسانهٔ نقطهٔ کور ستونها
یک نگرانی رایج وقتی چهار پیچ گوشهٔ سنگین رو عمودی از کنار یک لیدار دو بعدی چرخان رد میکنی اینه که آیا این ستونها تو ابر نقاط، مانع خیالی یا نقطهٔ کور بزرگ ایجاد میکنن یا نه.
تو تستهای عملیاتی زنده با پایپلاین بهینهشدهٔ SLAM در پایتون، ثابت شد که هیچ فیلتر نرمافزاری یا تغییر کدی برای نادیده گرفتن این ستونها لازم نیست! چون پیچهای ساختاری خیلی نازکاند، سطح مقطعشون کاملا زیر آستانهٔ تفکیک هندسی و زاویهای پالسهای لیزری RPLidar A1 قرار میگیره.
در نتیجه کد به راحتی این ستونها رو به عنوان نویز ناچیز رد میکنه و ربات میتونه محیط اطرافش رو با یک دید 360 درجهٔ تمیز و پیوسته نقشهبرداری کنه.
طراحی مدار برق دوخطهٔ ایزوله و قفل ایمنی با ماسفت
یکی از رایجترین دلایل ناپایداری تو رباتیکهای متحرک، افت ولتاژ و نویز القایی هست. وقتی موتورهای DC ناگهان جهت عوض میکنن یا از حالت سکون شروع به کار میکنن، یک جهش جریان خیلی زیاد میکشن.
اگه واحد پردازشی روی همون خط برق موتورها باشه، این جهشهای جریان باعث افت موقت ولتاژ میشن و همین کافیه که رزبریپای 5 سریع کرش کنه یا خودش ریست بشه.
برای اینکه این مشکل کاملا از بین بره، تو این ربات یک معماری برق دوخطهٔ کاملا ایزوله پیاده کردیم، به همراه فیلتر خازنی و یک قفل ایمنی با ماسفت کانال N. حالا ببینیم سیستم برق چطور تقسیم و پایدار شده:
1. خط لاجیک (برق تمیز)
این خط یک منبع ولتاژ خیلی پایدار و بدون نویز فراهم میکنه که فقط مخصوص مغز پردازشی و سنسورهای حساس هست.
- منبع: یک پک مستقل 5S از سلولهای 18650 که ولتاژش بین حدود 18.5 ولت (خالی) تا 21 ولت (پر) هست.
- تنظیم ولتاژ: این خط پرولتاژ مستقیم وارد مبدل باک کاهندهٔ 5 ولت / 5 آمپر با بازدهی بالا میشه. مبدل باک ولتاژ رو دقیق روی 5 ولت ثابت میکنه و از طریق پورت USB-C مستقیم به ورودی برق رزبریپای 5 متصل میشه.
- کنترل: یک کلید فشاری مکانیکی پرجریان اختصاصی سری با خروجی باتری وصل شده تا وقتی میخوای ربات رو خاموش کنی، برق خط لاجیک کاملا قطع بشه. یک خازن الکترولیتی 100 میکروفاراد هم موازی این کلید بذار تا نویز سوییچینگ از بین بره.
2. خط قدرت و پیشرانش (برق کثیف)
این خط مسئول تأمین جریان بالای لازم برای حرکت دادن بار فیزیکی هست و بازخورد القایی موتورها رو کاملا از سیستم لاجیک جدا نگه میداره.
- منبع: یک پک مستقل 3S از سلولهای 18650 با ولتاژی بین 11.1 ولت (خالی) تا 12.6 ولت (پر) که دقیقا با نیاز ولتاژی موتورهای گیربکسی 12 ولتی JGA25-370 همخوانی داره.
- انتقال: این خط مستقیم به ترمینالهای ورودی پرجریان درایور موتور دو H-بریج مدل ZK-5AD وصل میشه.
- کنترل: یک کلید فشاری مکانیکی مستقل دوم جریان اصلی این خط رو کنترل میکنه، تا بتونی موقع تست بدون خاموش کردن رزبریپای، برق موتورها رو قطع کنی. اینجا هم یک خازن 100 میکروفاراد موازی کلید بذار تا نویز سوییچینگ رو حذف کنی.
4. قفل ایمنی
برای یک لایهٔ حفاظتی اضافه، یک ماسفت قدرت کانال N (مثل IRF540N) تو مسیر گراند سمت پایین خط پیشرانش قرار میگیره.
- کارکردش چیه: ماسفت مثل یک دروازهٔ الکترونیکی عمل میکنه. حتی اگه کلید فیزیکی باتری موتور هم روشن باشه، درایور موتور نمیتونه برق بکشه مگه اینکه یک شرط خاص برقرار باشه؛ این یعنی هیچ حرکت ناخواستهای موقع بوت شدن رزبریپای اتفاق نمیافته. وقتی سیستمعامل رزبریپای کامل بالا میاد و پینهای GPIO رو مقداردهی میکنه، اسکریپت گیت ماسفت رو فعال میکنه، مدار کامل میشه و سیستم پیشرانش برای ناوبری خودران آماده به کار میشه.
🔍 سیمکشی فیزیکی و پیناوت ماسفت
برای پیادهسازی این کلید سمت پایین بهدرستی، ماسفت کانال N رو دقیقا با این چیدمان سیمکشی کن:
- درین (D): این پین رو مستقیم به سیم گراند درایور موتور ZK-5AD وصل کن. اینطوری مسیر برگشت درایور به باتری کاملا قطع میمونه مگه ماسفت فعال بشه.
- سورس (S): این پین رو مستقیم به قطب منفی (گراند) پک باتری موتور 3S وصل کن.
- گیت (G): این پین رو از طریق یک مقاومت کوچک محدودکنندهٔ جریان (مثل 220 یا 330 اهم) به پین 5 ولت رزبریپای 5 وصل کن، تا از ورود جریان ناگهانی به پورت رزبریپای جلوگیری بشه.
- مقاومت پول-داون: یک مقاومت پول-داون متوسط (معمولا 10 کیلواهم) رو مستقیم بین پین گیت (G) و خط گراند سورس (S) پک باتری لحیم کن.
جدول کامل سیمکشی و پیناوت سختافزار
حالا که زیرساخت برق دوخطه آمادهس، وقتشه خطوط کنترلی بین رزبریپای 5، سنسورها و بخشهای محرک رو مشخص کنیم. چون سیستم کنترل پایتونی ما همه چیز رو مستقیم روی یک پردازندهٔ خام و بدون میکروکنترلر واسط اجرا میکنه، سیمکشی دقیق به پینهای مشخصشدهٔ GPIO با شمارهٔ Broadcom (BCM) خیلی مهمه. از جدولهای مرجع زیر برای برقراری اتصالات استفاده کن.
1. اتصالات محرک و درایور موتور
این پینها سیگنالهای PWM و جهت دیجیتال رو از رزبریپای 5 به ورودیهای درایور H-بریج ماژول ZK-5AD میفرستن تا سرعت و جهت چرخها کنترل بشه. اتصال موتور چپ - جهت جلو (IN1) به پین GPIO 4، یعنی پین فیزیکی شمارهٔ 7. اتصال موتور چپ - جهت عقب (IN2) به پین GPIO 17، یعنی پین فیزیکی شمارهٔ 11. اتصال موتور راست - جهت جلو (IN3)
به پین GPIO 22 یعنی پین فیزیکی شمارهٔ 15 وصل میشه. اتصال موتور راست - جهت عقب (IN4) هم به پین GPIO 27، یعنی پین فیزیکی شمارهٔ 13 وصل میشه.
2. انکودرهای کوادراچر با فرکانس بالا
موتورهای JGA25-370 انکودر افکت هال داخلی دارن که پالسهای پرفرکانس برای ردیابی چرخش فیزیکی چرخ به رزبریپای میفرستن. کانال A انکودر چپ روی پین GPIO 12 (پین فیزیکی 32) و کانال B انکودر چپ روی پین GPIO 16 (پین فیزیکی 36) قرار داره. کانال A انکودر راست روی پین GPIO 25 (پین فیزیکی 22) و کانال B اون
روی پین GPIO 24 (پین فیزیکی 18) هست. تغذیهٔ برق (VCC) هر دو انکودر از خط 3.3 ولت (پین فیزیکی 1 یا 17) تأمین میشه و گراند هر دو انکودر هم به خط گراند (پین فیزیکی 6، 9 یا 14) وصل میشه.
3. سنسورهای فضایی I2C و USB
سنسور IMU مدل MPU6050 تغییرات سرعت زاویهای رو از طریق باس سختافزاری I2C میخونه، در حالی که RPLidar A1 دادههای خام ابر نقاط رو از طریق پل اختصاصی USB به UART خودش منتقل میکنه. پین SDA سنسور MPU6050 به GPIO 2 یا همون I2C1 SDA (پین فیزیکی 3) و پین SCL اون به GPIO 3 یا I2C1 SCL (پین فیزیکی 5) وصل میشه.
تغذیهٔ VCC سنسور MPU6050 از خط 3.3 ولت (پین فیزیکی 1) و گراند اون از خط گراند (پین فیزیکی 9) تأمین میشه. کابل ارتباطی RPLidar A1 هم به هر پورت USB 2.0 یا 3.0 وصل میشه و به آدرس /dev/ttyUSB0 دیده میشه.
4. چیدمان قفل سختافزاری
گیت ماسفت کانال N مستقیم به خط اصلی برق رزبریپای وصل میشه. این باعث میشه یک مدار قفل ایمنی کاملا سختافزاری و ساده شکل بگیره.
- چگونگی اتصال: گیت (G) ماسفت مستقیم به یک پین برق 5 ولت (پین فیزیکی 2 یا 4) روی رزبریپای 5 وصل میشه، البته با یک مقاومت محافظ سری کوچک (220 اهم). یک مقاومت پول-داون 10 کیلواهمی هم بین گیت (G) و خط سورس (S) که به قطب منفی پک باتری موتور 3S وصله، قرار میگیره.
- منطق قفل: وقتی کل سیستم خاموشه، خط 5 ولت کاملا بیبرقه (0 ولت). مقاومت پول-داون گیت ماسفت رو محکم به گراند میچسبونه و از عبور هر جریانی از سمت پایین درایور موتور جلوگیری میکنه. به محض اینکه کلید اصلی لاجیک فشرده بشه و رزبریپای شروع به بوت کنه، خط 5 ولت سختافزاری فعال میشه. این ولتاژ پیوسته گیت رو فعال میکنه، حلقهٔ گراند سمت پایین رو میبنده و درست همون لحظه که محیط پردازشی آماده میشه، سیستم پیشرانش هم مسلح میشه!
💡 چکلیست قبل از ادامهٔ مونتاژ
- دوباره چک کن که گراند خط لاجیک (رزبریپای) و گراند خط قدرت موتور (منفی باتری 3S) از طریق مسیر برگشت سمت پایین ماسفت درست به هم وصل باشن.
- مطمئن شو هیچ رشته سیم اضافهای پینهای کوچک درایور H-بریج رو اتصالی نکرده باشه.
آمادهسازی سیستمعامل، محیط مجازی و نصب پیشنیازهای کد
چون این معماری از میکروکنترلرهای سطح پایین صرفنظر میکنه و فیوژن سنسور، نقشهبرداری SLAM و مسیریابی A* رو مستقیم روی رزبریپای 5 و به صورت چندنخی اجرا میکنه، تنظیم درست محیط سیستمعامل خیلی اهمیت داره.
سیستمعامل Raspberry Pi OS نسخهٔ 64 بیتی Bookworm از استاندارد PEP 668 پیروی میکنه، یعنی دیگه نمیتونی با دستور معمولی pip install پکیجهای پایتون رو به صورت گلوبال نصب کنی؛ اگه این کار رو بکنی با خطای externally-managed-environment مواجه میشی. برای دور زدن این محدودیت به شکلی تمیز، یک محیط مجازی (venv) اختصاصی میسازیم که به کتابخانههای سختافزاری سیستم هم دسترسی داره.
این دستورات ترمینال رو قدم به قدم دنبال کن تا محیط نرمافزاری بدون مانیتور آماده بشه.
1. آپدیت سیستم و فعالسازی I2C
اول ترمینال رو باز کن (چه از طریق SSH و چه ترمینال دسکتاپ) و مخازن پکیجهات رو بهروز کن:
sudo apt update && sudo apt upgrade -y
بعدش باید باس سختافزاری I2C رزبریپای رو فعال کنی تا بتونه با سنسور MPU6050 ارتباط برقرار کنه. ابزار تنظیمات سیستم رو باز کن:
sudo raspi-config
- با کلیدهای جهتدار برو روی گزینهٔ 3 Interface Options.
- گزینهٔ I4 I2C رو انتخاب کن.
- برای فعال کردن رابط ARM I2C گزینهٔ Yes رو بزن.
- روی Finish برو و اینتر بزن.
2. اعطای دسترسی سریال به لیدار
به طور پیشفرض، حسابهای کاربری معمولی لینوکس دسترسی فوری برای خوندن جریان دادهٔ خام از پلهای USB به UART (مثل چیپ CP2102 روی کنترلر RPLidar A1) ندارن. اگه اسکریپت رو بدون تغییر دسترسیها اجرا کنی، موقع مقداردهی اولیهٔ /dev/ttyUSB0 با خطای عدم دسترسی مواجه میشی. برای اینکه کاربرت دسترسی دائمی به پورتهای سریال سختافزاری داشته باشه، خودت رو به گروه dialout اضافه کن:
sudo usermod -a -G dialout $USER
3. ساخت محیط مجازی رباتیک فضایی
برای نصب امن مجموعه کتابخانههای پایتون رباتیک روی نسخهٔ Bookworm، یک محیط مجازی میسازیم. پرچم --system-site-packages رو هم اضافه میکنیم. این کار روی رزبریپای 5 خیلی مهمه، چون اجازه میده محیط مجازی از کتابخانههای از پیش کامپایلشدهٔ سیستم برای کار با GPIO سختافزاری (مثل بکاندهای lgpio) استفاده کنه. دستورات زیر رو اجرا کن تا محیط مجازی ساخته و فعال بشه:
# Create a virtual environment named 'robot_env' in your home folder
python3 -m venv --system-site-packages ~/robot_env
# Activate the virtual environment
source ~/robot_env/bin/activate
بعد از فعالسازی، خط فرمان ترمینالت با (robot_env) نشون داده میشه که یعنی هر نصب پایتونی بعدی داخل همین محیط جدا باقی میمونه.
4. نصب وابستگیهای پروژه با pip
حالا که محیط مجازی فعاله، این دستور یکپارچهٔ نصب رو اجرا کن تا دقیقا همون کتابخانههای شخصثالثی که اسکریپت کنترلی ما نیاز داره نصب بشه:
pip3 install pygame numpy scipy mpu6050-raspberrypi rplidar-roboticia
چرا دقیقا همین کتابخانهها؟
- pygame: موتور گرافیکی سبک ما رو اجرا میکنه، کلیکهای ماوس روی گرید نقشه رو پردازش میکنه و ورودیهای کیبورد رو ردیابی میکنه.
- numpy و scipy: محاسبات برداری پیچیده رو انجام میدن. scipy.spatial.KDTree توسط الگوریتم تطبیق اسکن ICP برای محاسبهٔ سریع نزدیکترین همسایه استفاده میشه، و scipy.ndimage.binary_dilation دیوارها رو گسترش میده تا لایهٔ ایمنی 10 سانتیمتری مانع پویا ساخته بشه.
- mpu6050-raspberrypi: ثبتهای I2C سطح پایین رو مدیریت میکنه تا سرعت چرخشی از ژیروسکوپ استخراج بشه.
- rplidar-roboticia: یک نسخهٔ بهینهشده و پایدار از کتابخانهٔ کلاسیک RPLidar که جریان دادهٔ پسزمینه از دیود لیزری رو بدون گیر انداختن نخهای اجرایی مدیریت میکنه.
5. تست چیدمان سنسورها
قبل از نوشتن یا اجرای پایپلاین اصلی کنترل، با اجرای این دستور مطمئن شو که سنسور MPU6050 روی باس I2C درست کار میکنه:
i2cdetect -y 1
باید یک جدول با عدد 68 تو یکی از ستونها ببینی. این یعنی IMU درست سیمکشی شده، برق داره و به آدرس سختافزاری پیشفرضش (0x68) پاسخ میده.
نوشتن اسکریپت پایتون برای ناوبری خودران و نقشهبرداری SLAM
حالا که سختافزار سیمکشی شده و محیط مجازی سیستمعامل کامل آمادهس، وقتشه هوش اصلی ربات رو پیادهسازی کنیم. این یک اسکریپت پایتون یکپارچه هست که همهچیز رو همزمان تو یک حلقهٔ اجرایی 20 هرتزی مدیریت میکنه:
- فیوژن سنسور: شمارش خام انکودر چرخها رو با دادهٔ محور Z ژیروسکوپ MPU6050 با یک فیلتر مکمل ترکیب میکنه تا اودومتری ردیابی بشه.
- نقشهبرداری SLAM: یک روتین تطبیق ICP اجرا میکنه تا اسکنهای لحظهای RPLidar A1 با دادههای قبلی همتراز بشن و یک نقشهٔ اشغال با وضوح 5 سانتیمتر پیوسته بهروزرسانی بشه.
- مسیریابی A*: هر بار که روی نقشهٔ زندهٔ رابط کاربری کلیک میکنی، یک مسیر بهینه و بدون برخورد از میان نقشهٔ گسترشیافتهٔ موانع میسازه.
- فرماندهی خودران: یک روتین مسیریابی Pure Pursuit رو اجرا میکنه که با یک کنترلر PID فرمانگیری تثبیت میشه تا حرکت نرم بمونه و از حرکت مارپیچی جلوگیری بشه.
1. ساخت فایل اسکریپت روی رزبریپای
تو نشست فعال SSH یا ترمینال، مطمئن شو محیط مجازی فعاله (source ~/robot_env/bin/activate) و یک فایل پایتون جدید با ویرایشگر nano باز کن:
nano ugv_code.py
2. کپی و جایگذاری کل کد
کل بلوک اسکریپت زیر رو کپی کن و مستقیم تو پنجرهٔ ویرایشگر ترمینال جایگذاری کن:
import math
import pygame
import time
import numpy as np
import heapq
from scipy.spatial import KDTree
from scipy.ndimage import binary_dilation
from gpiozero import Motor, RotaryEncoder
from mpu6050 import mpu6050
from rplidar import RPLidar, RPLidarException
# ---------------- CONSTANTS & HARDWARE ----------------
TICKS_PER_REV = 48
WHEEL_RADIUS_M = 0.032
WHEEL_CIRCUMFERENCE_M = 2 * math.pi * WHEEL_RADIUS_M
METERS_PER_TICK = WHEEL_CIRCUMFERENCE_M / TICKS_PER_REV
WHEEL_BASE_M = 0.17
left_motor = Motor(forward=4, backward=17)
right_motor = Motor(forward=22, backward=27)
right_encoder = RotaryEncoder(a=25, b=24, max_steps=1000000)
left_encoder = RotaryEncoder(a=12, b=16, max_steps=1000000)
sensor = mpu6050(0x68)
PORT_NAME = '/dev/ttyUSB0'
BAUD_RATE = 115200
# ---------------- SPEED & CALIBRATION ----------------
BASE_SPEED = 0.6
FWD_LEFT_TRIM = 0.9
FWD_RIGHT_TRIM = 1.0
BWD_LEFT_TRIM = 0.85
BWD_RIGHT_TRIM = 1.0
MIN_STALL_PWM = 0.35
def true_power(requested_speed):
requested_speed = max(0.0, min(1.0, requested_speed))
if requested_speed < 0.05:
return 0.0
return MIN_STALL_PWM + (requested_speed * (1.0 - MIN_STALL_PWM))
def backward(speed=BASE_SPEED):
left_motor.backward(true_power(speed * BWD_LEFT_TRIM))
right_motor.backward(true_power(speed * BWD_RIGHT_TRIM))
def left(speed=BASE_SPEED):
left_motor.backward(true_power(speed * BWD_LEFT_TRIM))
right_motor.forward(true_power(speed * FWD_RIGHT_TRIM))
def right(speed=BASE_SPEED):
left_motor.forward(true_power(speed * FWD_LEFT_TRIM))
right_motor.backward(true_power(speed * BWD_RIGHT_TRIM))
def stop():
left_motor.stop()
right_motor.stop()
class PIDController:
def __init__(self, kp, ki, kd):
self.kp = kp
self.ki = ki
self.kd = kd
self.prev_error = 0
self.integral = 0
def compute(self, target, current, dt):
error = target - current
self.integral += error * dt
derivative = (error - self.prev_error) / dt
self.prev_error = error
return (self.kp * error) + (self.ki * self.integral) + (self.kd * derivative)
def reset(self):
self.prev_error = 0
self.integral = 0
# Base PID for manual driving stabilization
pid = PIDController(kp=0.05, ki=0.0, kd=0.01)
# Autonomous Steering PID (Eliminates the "snaking")
nav_pid = PIDController(kp=0.8, ki=0.0, kd=0.15)
# ---------------- A* PATHFINDING ALGORITHM (OPTIMIZED) ----------------
def a_star(start_grid, goal_grid, occ_grid, width, height):
walls = (occ_grid == 100)
inflated_walls = binary_dilation(walls, iterations=2)
sx, sy = start_grid
inflated_walls[max(0, sx-1):min(width, sx+2), max(0, sy-1):min(height, sy+2)] = False
if inflated_walls[goal_grid[0], goal_grid[1]]:
print("Goal is blocked or too close to a wall!")
return []
neighbors = [(0,1), (1,0), (0,-1), (-1,0), (1,1), (-1,1), (1,-1), (-1,-1)]
open_set = []
heapq.heappush(open_set, (0, start_grid))
came_from = {}
g_score = {start_grid: 0}
max_nodes = 20000
nodes_expanded = 0
while open_set and nodes_expanded < max_nodes:
_, current = heapq.heappop(open_set)
nodes_expanded += 1
if current == goal_grid:
path = []
while current in came_from:
path.append(current)
current = came_from[current]
path.reverse()
return path
for dx, dy in neighbors:
nx, ny = current[0] + dx, current[1] + dy
if 0 <= nx < width and 0 <= ny < height:
if inflated_walls[nx, ny]:
continue
cost = 1.414 if abs(dx) == 1 and abs(dy) == 1 else 1.0
tentative_g = g_score[current] + cost
if (nx, ny) not in g_score or tentative_g < g_score[(nx, ny)]:
came_from[(nx, ny)] = current
g_score[(nx, ny)] = tentative_g
h = math.hypot(goal_grid[0] - nx, goal_grid[1] - ny)
heapq.heappush(open_set, (tentative_g + h, (nx, ny)))
print("Pathfinding timeout or route unreachable.")
return []
# ---------------- VOXEL GRID FILTER ----------------
def voxel_filter(points, leaf_size_m=0.05):
if len(points) == 0:
return points
pts = np.array(points)
grid_coords = np.round(pts / leaf_size_m).astype(int)
_, indices = np.unique(grid_coords, axis=0, return_index=True)
return pts[indices].tolist()
# ---------------- OCCUPANCY GRID ----------------
class OccupancyGrid:
def __init__(self, width_m, height_m, resolution_m):
self.resolution = resolution_m
self.grid_width = int(width_m / resolution_m)
self.grid_height = int(height_m / resolution_m)
self.grid = np.full((self.grid_width, self.grid_height), -1, dtype=np.int8)
self.origin_cx = self.grid_width // 2
self.origin_cy = self.grid_height // 2
def world_to_grid(self, x, y):
cx = int(x / self.resolution) + self.origin_cx
cy = int(y / self.resolution) + self.origin_cy
return cx, cy
def bresenham(self, x0, y0, x1, y1):
cells = []
dx = abs(x1 - x0)
dy = abs(y1 - y0)
x, y = x0, y0
sx = -1 if x0 > x1 else 1
sy = -1 if y0 > y1 else 1
if dx > dy:
err = dx / 2.0
while x != x1:
cells.append((x, y))
err -= dy
if err < 0:
y += sy
err += dx
x += sx
else:
err = dy / 2.0
while y != y1:
cells.append((x, y))
err -= dx
if err < 0:
x += sx
err += dy
y += sy
cells.append((x, y))
return cells
def update(self, robot_x, robot_y, global_lidar_points):
rx, ry = self.world_to_grid(robot_x, robot_y)
for pt_x, pt_y in global_lidar_points:
wx, wy = self.world_to_grid(pt_x, pt_y)
if 0 <= wx < self.grid_width and 0 <= wy < self.grid_height:
line_cells = self.bresenham(rx, ry, wx, wy)
for (cx, cy) in line_cells[:-1]:
if 0 <= cx < self.grid_width and 0 <= cy < self.grid_height:
if self.grid[cx, cy] != 100:
self.grid[cx, cy] = 0
self.grid[wx, wy] = 100
# ---------------- ICP ALGORITHM ----------------
def icp_match(prev_points, curr_points, initial_guess=(0.0, 0.0, 0.0), max_iterations=15):
if len(prev_points) < 10 or len(curr_points) < 10:
return initial_guess
dx, dy, dtheta = initial_guess
R = np.array([[np.cos(dtheta), -np.sin(dtheta)],
[np.sin(dtheta), np.cos(dtheta)]])
t = np.array([dx, dy])
src = np.dot(curr_points, R.T) + t
dst = np.array(prev_points)
for _ in range(max_iterations):
tree = KDTree(dst)
distances, indices = tree.query(src)
valid = distances < 0.2
src_matched = src[valid]
dst_matched = dst[indices[valid]]
if len(src_matched) < 10:
break
centroid_src = np.mean(src_matched, axis=0)
centroid_dst = np.mean(dst_matched, axis=0)
src_centered = src_matched - centroid_src
dst_centered = dst_matched - centroid_dst
H = np.dot(src_centered.T, dst_centered)
U, S, Vt = np.linalg.svd(H)
R_opt = np.dot(Vt.T, U.T)
if np.linalg.det(R_opt) < 0:
Vt[1, :] *= -1
R_opt = np.dot(Vt.T, U.T)
t_opt = centroid_dst - np.dot(centroid_src, R_opt.T)
src = np.dot(src, R_opt.T) + t_opt
c_curr = np.mean(curr_points, axis=0)
c_src = np.mean(src, axis=0)
curr_centered = curr_points - c_curr
src_centered = src - c_src
H_final = np.dot(curr_centered.T, src_centered)
U_f, S_f, Vt_f = np.linalg.svd(H_final)
R_total = np.dot(Vt_f.T, U_f.T)
if np.linalg.det(R_total) < 0:
Vt_f[1, :] *= -1
R_total = np.dot(Vt_f.T, U_f.T)
net_dtheta = math.atan2(R_total[1, 0], R_total[0, 0])
t_total = c_src - np.dot(c_curr, R_total.T)
net_dx = t_total[0]
net_dy = t_total[1]
return net_dx, net_dy, net_dtheta
# ---------------- MAIN LOGGER LOOP ----------------
def run_logger():
pygame.init()
screen = pygame.display.set_mode((800, 800))
pygame.display.set_caption("UGV Live Mapping & Autonomous A*")
font = pygame.font.SysFont(None, 24)
map_surface = pygame.Surface((800, 800))
map_surface.fill((0, 0, 0))
SCALE = 80
CENTER_X, CENTER_Y = 400, 400
occupancy_grid = OccupancyGrid(width_m=20.0, height_m=20.0, resolution_m=0.05)
print("Connecting to LiDAR...")
lidar = RPLidar(PORT_NAME, baudrate=BAUD_RATE, timeout=3)
lidar.start_motor()
time.sleep(2)
log_file = open("slam_log.csv", "w")
print("Recording to slam_log.csv...")
robot_x, robot_y, robot_theta = 0.0, 0.0, 0.0
current_yaw, target_yaw = 0.0, 0.0
odom_dx, odom_dy, odom_dtheta = 0.0, 0.0, 0.0
prev_left_ticks, prev_right_ticks = 0, 0
driving_forward = False
driving_backward = False
last_motor_time = time.time()
scan_distances = [0] * 360
scans_recorded = 0
global_map_points = []
# NAVIGATION VARIABLES
navigating = False
active_path = []
waypoint_index = 0
stable_frames = 10
try:
for new_scan, quality, angle, distance in lidar.iter_measures():
current_time = time.time()
# ==============================================================
# 1. THE FAST LOOP (Odometry, Control, & Autonomy at 20Hz)
# ==============================================================
if current_time - last_motor_time >= 0.05:
dt = current_time - last_motor_time
last_motor_time = current_time
gyro_data = sensor.get_gyro_data()
raw_gyro_z = gyro_data['z']
if abs(raw_gyro_z) < 1.5:
raw_gyro_z = 0.0
gyro_z_rad_per_sec = math.radians(raw_gyro_z)
imu_dtheta = gyro_z_rad_per_sec * dt
current_yaw += raw_gyro_z * dt
for event in pygame.event.get():
if event.type == pygame.QUIT or (event.type == pygame.KEYDOWN and event.key == pygame.K_ESCAPE):
raise KeyboardInterrupt
elif event.type == pygame.MOUSEBUTTONDOWN and event.button == 1:
mx, my = pygame.mouse.get_pos()
target_x = (mx - CENTER_X) / SCALE
target_y = (CENTER_Y - my) / SCALE
start_cx, start_cy = occupancy_grid.world_to_grid(robot_x, robot_y)
goal_cx, goal_cy = occupancy_grid.world_to_grid(target_x, target_y)
print("Calculating route...")
path_cells = a_star((start_cx, start_cy), (goal_cx, goal_cy), occupancy_grid.grid, occupancy_grid.grid_width, occupancy_grid.grid_height)
if path_cells:
active_path = []
for cx, cy in path_cells:
wx = (cx - occupancy_grid.origin_cx) * occupancy_grid.resolution
wy = (cy - occupancy_grid.origin_cy) * occupancy_grid.resolution
active_path.append((wx, wy))
navigating = True
waypoint_index = 0
nav_pid.reset()
print(f"Path locked! {len(active_path)} waypoints to destination.")
else:
print("Navigation Failed.")
elif event.type == pygame.KEYDOWN:
navigating = False
stop()
if event.key == pygame.K_UP:
target_yaw = current_yaw
pid.reset()
driving_forward = True
driving_backward = False
elif event.key == pygame.K_DOWN:
target_yaw = current_yaw
pid.reset()
driving_backward = True
driving_forward = False
elif event.key == pygame.K_LEFT:
driving_forward, driving_backward = False, False
left()
elif event.key == pygame.K_RIGHT:
driving_forward, driving_backward = False, False
right()
elif event.key == pygame.K_SPACE:
driving_forward, driving_backward = False, False
stop()
elif event.type == pygame.KEYUP:
if event.key in (pygame.K_UP, pygame.K_DOWN, pygame.K_LEFT, pygame.K_RIGHT):
driving_forward, driving_backward = False, False
stop()
# -- MANUAL DRIVING --
if driving_forward:
adjustment = pid.compute(target_yaw, current_yaw, dt)
raw_left = (BASE_SPEED * FWD_LEFT_TRIM) - adjustment
raw_right = (BASE_SPEED * FWD_RIGHT_TRIM) + adjustment
left_motor.forward(true_power(raw_left))
right_motor.forward(true_power(raw_right))
elif driving_backward:
adjustment = pid.compute(target_yaw, current_yaw, dt)
raw_left = (BASE_SPEED * BWD_LEFT_TRIM) + adjustment
raw_right = (BASE_SPEED * BWD_RIGHT_TRIM) - adjustment
left_motor.backward(true_power(raw_left))
right_motor.backward(true_power(raw_right))
# -- AUTONOMOUS PATH FOLLOWER (PURE PURSUIT + PID) --
elif navigating and len(active_path) > 0:
min_dist = float('inf')
closest_idx = waypoint_index
search_window = min(waypoint_index + 30, len(active_path))
for i in range(waypoint_index, search_window):
wx, wy = active_path[i]
dist = math.hypot(wx - robot_x, wy - robot_y)
if dist < min_dist:
min_dist = dist
closest_idx = i
waypoint_index = closest_idx
final_wx, final_wy = active_path[-1]
dist_to_goal = math.hypot(final_wx - robot_x, final_wy - robot_y)
if dist_to_goal < 0.25:
navigating = False
stop()
print("Destination Reached!")
else:
LOOKAHEAD_STEPS = 8
target_idx = min(waypoint_index + LOOKAHEAD_STEPS, len(active_path) - 1)
target_wx, target_wy = active_path[target_idx]
dx = target_wx - robot_x
dy = target_wy - robot_y
target_angle = math.atan2(dy, dx)
angle_diff = (target_angle - robot_theta + math.pi) % (2 * math.pi) - math.pi
if abs(angle_diff) > 0.4:
if angle_diff > 0:
left_motor.backward(true_power(0.4))
right_motor.forward(true_power(0.4))
else:
left_motor.forward(true_power(0.4))
right_motor.backward(true_power(0.4))
else:
steer_adjust = nav_pid.compute(0, -angle_diff, dt)
speed_multiplier = max(0.4, 1.0 - abs(angle_diff))
fwd_speed = BASE_SPEED * speed_multiplier
raw_left = (fwd_speed * FWD_LEFT_TRIM) - steer_adjust
raw_right = (fwd_speed * FWD_RIGHT_TRIM) + steer_adjust
left_motor.forward(true_power(raw_left))
right_motor.forward(true_power(raw_right))
# -- READ ENCODERS & SENSOR FUSION --
current_left_ticks = left_encoder.steps
current_right_ticks = right_encoder.steps
d_left = (current_left_ticks - prev_left_ticks) * METERS_PER_TICK
d_right = (current_right_ticks - prev_right_ticks) * METERS_PER_TICK
d_center = (d_left + d_right) / 2.0
encoder_dtheta = (d_right - d_left) / WHEEL_BASE_M
ALPHA = 0.85
fused_dtheta = (ALPHA * imu_dtheta) + ((1.0 - ALPHA) * encoder_dtheta)
odom_dx += d_center * math.cos(odom_dtheta)
odom_dy += d_center * math.sin(odom_dtheta)
odom_dtheta += fused_dtheta
prev_left_ticks = current_left_ticks
prev_right_ticks = current_right_ticks
# ==============================================================
# 2. THE SLOW LOOP (Scan-to-Map SLAM)
# ==============================================================
if quality > 10 and 200 <= distance <= 3000:
angle_idx = min(359, int(angle))
scan_distances[angle_idx] = distance / 1000.0
if new_scan:
curr_scan_local = []
for angle_deg, dist_m in enumerate(scan_distances):
if dist_m > 0:
adjusted_angle = (angle_deg + 180) % 360
angle_rad = math.radians((360.0 - adjusted_angle) % 360)
lx = dist_m * math.cos(angle_rad)
ly = dist_m * math.sin(angle_rad)
curr_scan_local.append([lx, ly])
curr_scan_local = voxel_filter(curr_scan_local, leaf_size_m=0.05)
turn_speed = abs(math.degrees(odom_dtheta))
if turn_speed > 0.5:
stable_frames = 0
else:
stable_frames += 1
is_safe_to_map = stable_frames >= 3
robot_theta += odom_dtheta
robot_x += odom_dx * math.cos(robot_theta) - odom_dy * math.sin(robot_theta)
robot_y += odom_dx * math.sin(robot_theta) + odom_dy * math.cos(robot_theta)
global_hits = []
if len(global_map_points) > 50 and len(curr_scan_local) > 10:
if is_safe_to_map:
local_map_target = []
for gx, gy in global_map_points:
if abs(gx - robot_x) < 3.0 and abs(gy - robot_y) < 3.0:
dx_p = gx - robot_x
dy_p = gy - robot_y
lx = dx_p * math.cos(robot_theta) + dy_p * math.sin(robot_theta)
ly = -dx_p * math.sin(robot_theta) + dy_p * math.cos(robot_theta)
local_map_target.append([lx, ly])
if len(local_map_target) > 20:
if len(local_map_target) > 500:
local_map_target = np.random.permutation(local_map_target)[:500].tolist()
corr_dx, corr_dy, corr_dtheta = icp_match(
local_map_target,
np.array(curr_scan_local),
(0.0, 0.0, 0.0)
)
# --- ICP CLAMPING TO PREVENT MAP SNAPPING ---
MAX_SHIFT = 0.05
MAX_ANGLE = 0.035
corr_dx = max(-MAX_SHIFT, min(MAX_SHIFT, corr_dx))
corr_dy = max(-MAX_SHIFT, min(MAX_SHIFT, corr_dy))
corr_dtheta = max(-MAX_ANGLE, min(MAX_ANGLE, corr_dtheta))
robot_theta += corr_dtheta
robot_x += corr_dx * math.cos(robot_theta) - corr_dy * math.sin(robot_theta)
robot_y += corr_dx * math.sin(robot_theta) + corr_dy * math.cos(robot_theta)
for lx, ly in curr_scan_local:
gx = robot_x + (lx * math.cos(robot_theta) - ly * math.sin(robot_theta))
gy = robot_y + (lx * math.sin(robot_theta) + ly * math.cos(robot_theta))
global_hits.append([gx, gy])
if is_safe_to_map:
occupancy_grid.update(robot_x, robot_y, global_hits)
global_map_points.extend(global_hits)
global_map_points = voxel_filter(global_map_points, leaf_size_m=0.1)
if len(global_map_points) > 3000:
global_map_points = global_map_points[-3000:]
for pt_x, pt_y in global_hits:
pt_px = int(CENTER_X + (pt_x * SCALE))
pt_py = int(CENTER_Y - (pt_y * SCALE))
if 0 <= pt_px < 800 and 0 <= pt_py < 800:
map_surface.set_at((pt_px, pt_py), (255, 0, 0))
elif len(global_map_points) <= 50:
for lx, ly in curr_scan_local:
gx = robot_x + (lx * math.cos(robot_theta) - ly * math.sin(robot_theta))
gy = robot_y + (lx * math.sin(robot_theta) + ly * math.cos(robot_theta))
global_hits.append([gx, gy])
occupancy_grid.update(robot_x, robot_y, global_hits)
global_map_points.extend(global_hits)
odom_dx, odom_dy, odom_dtheta = 0.0, 0.0, 0.0
# ---> DYNAMIC VISUALS <---
screen.fill((0, 0, 0))
screen.blit(map_surface, (0, 0))
if navigating and len(active_path) > 0:
for i in range(waypoint_index, len(active_path) - 1):
p1_x = int(CENTER_X + (active_path[i][0] * SCALE))
p1_y = int(CENTER_Y - (active_path[i][1] * SCALE))
p2_x = int(CENTER_X + (active_path[i+1][0] * SCALE))
p2_y = int(CENTER_Y - (active_path[i+1][1] * SCALE))
pygame.draw.line(screen, (0, 0, 255), (p1_x, p1_y), (p2_x, p2_y), 2)
for pt_x, pt_y in global_hits:
pt_px = int(CENTER_X + (pt_x * SCALE))
pt_py = int(CENTER_Y - (pt_y * SCALE))
if 0 <= pt_px < 800 and 0 <= pt_py < 800:
pygame.draw.circle(screen, (255, 165, 0), (pt_px, pt_py), 1)
robot_px = int(CENTER_X + (robot_x * SCALE))
robot_py = int(CENTER_Y - (robot_y * SCALE))
end_x = robot_px + int(15 * math.cos(robot_theta))
end_y = robot_py - int(15 * math.sin(robot_theta))
pygame.draw.line(screen, (0, 255, 255), (robot_px, robot_py), (end_x, end_y), 2)
pygame.draw.circle(screen, (0, 255, 0), (robot_px, robot_py), 5)
status_str = "Auto Navigating" if navigating else ("Mapping" if is_safe_to_map else "Waiting for Scan...")
img = font.render(f"Scans: {scans_recorded} | Status: {status_str}", True, (255, 255, 255))
screen.blit(img, (20, 20))
pygame.display.flip()
log_distances = [int(d * 100) if d > 0 else 0 for d in scan_distances]
csv_line = f"{robot_x:.4f},{robot_y:.4f},{robot_theta:.4f}," + ",".join(map(str, log_distances)) + "\n"
log_file.write(csv_line)
scans_recorded += 1
scan_distances = [0] * 360
except KeyboardInterrupt:
print("\nStopping and saving map data...")
finally:
stop()
log_file.close()
np.save("occupancy_map.npy", occupancy_grid.grid)
lidar.stop()
lidar.disconnect()
pygame.quit()
print(f"Data Collection Complete. Map saved to occupancy_map.npy!")
if __name__ == "__main__":
run_logger()
3. ذخیرهٔ فایل اسکریپت
بعد از جایگذاری، کلیدهای Ctrl + O رو بزن و بعد اینتر رو بزن تا تغییرات ذخیره بشه. برای بستن ویرایشگر هم Ctrl + X رو بزن.
راهاندازی عملیاتی زنده و کنترل نقشهبرداری ربات
حالا که سختافزار کامل مونتاژ شده و اسکریپت اصلی ناوبری ذخیره شده، وقتشه ربات رو روشن کنی. چون این سیستم بدون مانیتور روی رزبریپای 5 اجرا میشه ولی رابط نقشهٔ تعاملی رو با Pygame رندر میکنه، از یک اتصال SSH همراه با VNC روی لپتاپ استفاده میکنیم تا ابر نقاط SLAM رو به صورت زنده ببینیم و مختصات هدف رو بفرستیم. این روند عملیاتی رو برای راهاندازی و رانندگی امن ربات دنبال کن.
1. بوت و راهاندازی اولیهٔ ربات
- هر کابل شارژری که به سلولهای 18650 وصله رو جدا کن.
- کلید فیزیکی خط لاجیک رو بزن. صبر کن تا الایدیهای وضعیت رزبریپای 5 روشن بشن و سیستمعامل بوت بشه.
- وقتی چراغ فعالیت سبز رزبریپای الگوی ثابتی گرفت، کلید خط پیشرانش رو بزن تا برق درایور موتور ZK-5AD وصل بشه. به لطف مدار قفل سختافزاری، موتورها تو این مرحلهٔ بوت کاملا بیحرکت و ایمن میمونن.
2. اجرای حلقهٔ کنترل
روی لپتاپت ترمینال یا خط فرمان رو باز کن و یک اتصال SSH به رزبریپای برقرار کن. وارد محیط مجازی شو و اسکریپت رو اجرا کن:
# Activate your robotics environment
source ~/robot_env/bin/activate
# Execute the master SLAM pipeline
python3 ugv_code.py
یک پنجرهٔ رابط کاربری Pygame با عنوان "UGV Live Mapping & Autonomous A*" روی صفحهٔ دسکتاپت باز میشه. یک دایرهٔ سبز دقیقا وسط پنجره میبینی که نشوندهندهٔ ربات هست و یک خط فیروزهای هم جهت حرکت روبهجلوش رو نشون میده.
3. چیدمان کنترل دستی نقشهبرداری
قبل از اینکه ربات رو کاملا به حالت خودران بذاری، بهتره اول خودت با کنترل دستی یک دور تو اتاق برونیش تا یک نقشهٔ اولیهٔ خام از دیوارها بسازی. روی پنجرهٔ نقشهٔ Pygame کلیک کن تا فوکوس بشه و از این کلیدهای صفحهکلید استفاده کن:
- کلید بالا: حرکت مستقیم به جلو. کنترلر PID فرمانگیری داخلی دائم به دادههای ژیروسکوپ MPU6050 نگاه میکنه تا جهت حرکت رو قفل کنه و ربات بدون انحراف مستقیم حرکت کنه.
- کلید پایین: حرکت مستقیم به عقب.
- کلید چپ: چرخش با شعاع صفر به سمت چپ.
- کلید راست: چرخش با شعاع صفر به سمت راست.
- کلید فاصله (اسپیس): توقف اضطراری فوری.
همونطور که حرکت میکنی، پیکسلهای قرمز روشن روی صفحه نقش میبندن که یعنی دیوارهای ساختمانی به صورت دائم تو نقشهٔ اشغال ذخیره شدن. نقاط نارنجی هم بازتاب لیزری لحظهای برخورد با موانع اطراف رو نشون میدن.
4. فعال کردن حالت خودران با مسیریابی A*
وقتی یک فضای بسته یا اتاق رو کامل نقشهبرداری کردی:
- با کلید اسپیس ربات رو کاملا متوقف کن.
- با ماوس روی هر نقطهٔ باز و نقشهشدهٔ سیاه، روی گرید Pygame کلیک چپ بزن.
- اسکریپت بلافاصله با الگوریتم مسیریابی A* یک مسیر بهینه محاسبه میکنه و همهٔ دیوارهای قرمز شناختهشده رو با یک حاشیهٔ ایمنی 10 سانتیمتری گسترش میده تا بدنهٔ ربات به گوشهها گیر نکنه.
- یک خط مسیر آبی روی نقشه ظاهر میشه و ربات خودش موتورها رو مسلح میکنه، جهتش رو تنظیم میکنه و با فرماندهی Pure Pursuit مسیر رو دنبال میکنه تا دقیقا به مختصات کلیک تو برسه!
5. خاموش کردن و ذخیرهٔ اطلاعات
وقتی تست تموم شد، کلید ESCAPE رو در حالی که پنجرهٔ Pygame فوکوس داره بزن. این کار یک توقف نرمافزاری تمیز رو اجرا میکنه:
- اسکریپت برق موتورهای گیربکسی رو کاملا قطع میکنه.
- دیود لیزری چرخان RPLidar به آرامی از چرخش میایسته و وارد حالت خواب کممصرف میشه.
- نقشهٔ کامل اتاق برای همیشه تو دایرکتوری خانگیت با نام occupancy_map.npy به عنوان فایل ماتریس NumPy ذخیره میشه که بعدا میتونی تو متلب یا اسکریپتهای پایتون برای تحلیل مسیر پیشرفتهتر ازش استفاده کنی!
در نهایت، کلیدهای مکانیکی برق رو هم فیزیکی خاموش کن تا عمر باتری حفظ بشه. امیدواریم این آموزش کمکت کرده باشه یک ربات تحویلکار خودران واقعی و کاربردی بسازی. اگه تو مسیر ساخت به مشکلی خوردی یا ایدهای برای بهبودش داری، حتما تو بخش نظرات با تیم آیسیمدار و بقیهٔ دوستان در میون بذار. ممنون که تا اینجا همراهمون بودی!
تو میتونی اولین سازنده باشی!
اگه پروژه رو ساختی به اشتراک بزار و اعتبار هدیه بگیر
این آموزش با استانداردهای اختصاصی آیسیمدار بازنویسی و بهینهسازی شده و تمامی حقوق انتشار آن متعلق به این مجموعه است و در صورت کپی یا بازنشر پیگرد قانونی خواهد داشت.








































گفتگوهای کاربران
هنوز نظری ثبت نشده؛ تو شروع کن!
هنوز نظری ثبت نشده؛ تو شروع کن!