はじめに
10年ほど前のIoTやIndustry 4.0ブームの際、ICTエンジニアの中にはICTとOTの融合による新規事業を目指して電子工作を学んだ方が多くいました。私もその一人です。数年間取り組んだ結果、簡単な回路図の作成や、PoCレベルのIoTデバイス制作ができるようになりました。
そして今、フィジカルAIのブームが到来しています。再び電子工作から学び始める好機ですが、今回は従来のIoT(センサー)に加え、ロボットや機械の制御(アクチュエータ)が鍵となります。
さらにMCPやA2Aの登場により、IT・OT、あるいは組織の壁を超えたシームレスな「フィジカルAI」の世界が広がろうとしています。この進化に、今からワクワクしています。
ICTエンジニアがいかにしてフィジカルAIを網羅的に学ぶか
フィジカルAIに興味はあっても、以下のようなハードルを感じる方も多いのではないでしょうか。
- ロボットが高価
- OT(制御技術)の世界が未知
- 物理学から長く遠ざかっている
- 習得すべき技術要素が多岐にわたる
- 「手を汚す(ハードを触る)」文化への心理的障壁
しかし、このエキサイティングな分野で仕事をするには、避けては通れません。以前、私はUnityを用いたフィジカルAIのシミュレーション手法を公開しました。
ただ、シミュレーションはあくまで仮想世界です。ある講演で東大の先生が、シミュレーションを実世界へ適用することを「プリントアウト(コピー)」と表現されていましたが、実機で動かしてこそ本当の学びと言えます。
そこで今回は、最大の壁である「コスト」を抑えるため、Amazonで購入できる最安級のロボットキットからスタートし、以下の要素を網羅的に学習するロードマップを描きました。
- 4DoFロボットアームの組み立てと駆動
- ロボットコントローラの自作
- システムセットアップ(USBカメラの歪み補正など)
- 画像座標から平面座標への変換(ArUcoマーカーによる姿勢推定、逆透視変換)
- ロボット制御MCPサーバの構築
- Gemini Live(音声)によるロボット制御アプリの開発
- Unityによるデジタルツインとの同期動作
※ 今回は、推論(学習済みモデル)をメインとし、高コストな学習フェーズはスキップします。
※ 高校1年生で習う数学や物理学の範囲で学んでいきたいと思います。それ以上高度な内容の場合は、遠慮なく、Gemini APIやOpenCVといったライブラリを活用します。
最初の第一歩:4DoFロボットの調達
まずは教材となるロボットアームを購入しました。
- 4DoFロボットアームキット: 約2,645円(Amazonで購入、中国より1週間で到着)
- PWMサーボモータドライバ (PCA9685): 999円
「4DoF(4 Degrees of Freedom)」は4自由度を意味し、今回は4つのサーボモータで制御します。電源は乾電池4本(6V)から供給し、PC(Mac)からUSBシリアル経由で制御するシンプルな構成を目指します。
ロボットアームの組み立て
開封と部品確認
キットにはアクリル板、ボルト、ナット、サーボモータ一式が含まれています。案の定、説明書は付属していません。
アクリル板の工作は、一度覚えると展示会の什器作りなどにも応用が効く便利なスキルです。
組立情報の探索
説明書がないため、ネット上で類似モデルの情報を探しました。以下のリソースが参考になります。
サーボモータの原点調整
このキットはTowerPro製MG90S(金属ギア)を使用。より安価なSG90(プラスチックギア)よりも低速時のカクツキが少なく、スムーズに動作します。
ロボットアームの組み立てにおいて、最も神経を使うのがサーボモータの「軸合わせ」です。一度アクリル部品をネジ止めしてしまうと後からの微調整が難しいため、固定前にサーボを正確な角度(原点)へ移動させておく必要があります。
今回は、生成AI(Gemini)に指示を出して専用のテストスケッチを作成し、この工程を効率化しました。
サーボモータ原点調整用スケッチ
#include <Servo.h>
// --- Pin Configuration ---
// The PWM pin you have connected the servo's signal wire to.
// On Arduino UNO, pins 9 and 10 are controlled by Timer1.
// Using pin 9 is a good choice.
const int SERVO_PIN = 9;
// --- Servo and Movement Configuration ---
Servo my_servo;
// We use scaled integers (microseconds * 1000) for smooth, fast ISR math.
// 0 deg = 544us, 180 deg = 2400us.
int min_pulse = 544;
int max_pulse = 2400;
float us_per_deg = (2400 - 544) / 180.0; // ~10.311
float current_velocity = 60.0; // Degrees per second
long current_pos_scaled; // Current position in microseconds * 1000
long target_pos_scaled; // Target position in microseconds * 1000
long step_scaled; // Step size in microseconds * 1000 per tick
unsigned long last_update_time = 0;
String inputBuffer = "";
// --- Setup Function ---
void setup() {
// Initialize Serial communication at 9600 baud
Serial.begin(9600);
Serial.println("Servo Controller Initialized.");
Serial.println("Send commands like 'rotation=90' or 'velocity=50'");
Serial.println("Calibration: 'min_pulse=544', 'max_pulse=2400'");
// Attach the servo. For SG90/MG90S, default pulse widths are usually fine.
// If your servo doesn't reach the full 0-180 range, you can fine-tune
// with my_servo.attach(SERVO_PIN, min_pulse_width, max_pulse_width);
my_servo.attach(SERVO_PIN, min_pulse, max_pulse);
// Initialize position at 90 degrees
float start_angle = 90.0;
long start_us = min_pulse + (long)(start_angle * us_per_deg);
current_pos_scaled = start_us * 1000;
target_pos_scaled = current_pos_scaled;
step_scaled = (long)(current_velocity * us_per_deg);
my_servo.writeMicroseconds(start_us);
last_update_time = micros();
}
// --- Servo Update Function ---
// Called from loop() every 1000us (1000Hz)
void updateServoMovement() {
long current = current_pos_scaled;
long target = target_pos_scaled;
if (current == target) {
return;
}
long step = step_scaled;
if (current < target) {
current += step;
if (current > target) {
current = target;
}
} else {
current -= step;
if (current < target) {
current = target;
}
}
current_pos_scaled = current;
// Convert back to microseconds (divide by 1000)
my_servo.writeMicroseconds((int)(current / 1000));
}
// --- Main Loop ---
// Handles servo updates and non-blocking serial communication.
void loop() {
// Check if it is time to update the servo (1000Hz)
if (micros() - last_update_time >= 1000) {
last_update_time += 1000;
updateServoMovement();
}
while (Serial.available() > 0) {
char c = Serial.read();
if (c == '\n') {
inputBuffer.trim();
parseCommand(inputBuffer);
inputBuffer = "";
} else {
inputBuffer += c;
}
}
}
// --- Command Parsing Function ---
void parseCommand(String cmd) {
int eqIndex = cmd.indexOf('=');
if (eqIndex == -1) {
Serial.println("Invalid command format. Use 'key=value'.");
return;
}
String key = cmd.substring(0, eqIndex);
String valueStr = cmd.substring(eqIndex + 1);
float value = valueStr.toFloat();
if (key == "rotation") {
// Constrain the angle to the valid servo range (0-180)
float angle = constrain(value, 0.0, 180.0);
// Calculate target in scaled microseconds
long new_target_scaled = (long)((min_pulse + angle * us_per_deg) * 1000);
target_pos_scaled = new_target_scaled;
Serial.print("Setting target rotation to: ");
Serial.println(angle);
} else if (key == "velocity") {
// Constrain velocity to a positive, reasonable value (e.g., 1 to 1000 deg/s)
current_velocity = constrain(value, 1.0, 1000.0);
// Calculate step size: (deg/sec * us/deg) / 1000Hz * 1000(scale)
// The /1000Hz and *1000(scale) cancel out.
long new_step_scaled = (long)(current_velocity * us_per_deg);
step_scaled = new_step_scaled;
Serial.print("Setting velocity to: ");
Serial.print(current_velocity);
Serial.println(" deg/s");
} else if (key == "min_pulse") {
min_pulse = (int)value;
us_per_deg = (max_pulse - min_pulse) / 180.0;
// Update servo attachment limits
my_servo.detach();
my_servo.attach(SERVO_PIN, min_pulse, max_pulse);
// Recalculate velocity step based on new range
step_scaled = (long)(current_velocity * us_per_deg);
Serial.print("Min pulse set to: "); Serial.println(min_pulse);
} else if (key == "max_pulse") {
max_pulse = (int)value;
us_per_deg = (max_pulse - min_pulse) / 180.0;
// Update servo attachment limits
my_servo.detach();
my_servo.attach(SERVO_PIN, min_pulse, max_pulse);
// Recalculate velocity step based on new range
step_scaled = (long)(current_velocity * us_per_deg);
Serial.print("Max pulse set to: "); Serial.println(max_pulse);
} else {
Serial.print("Unknown command: ");
Serial.println(key);
}
}
1. 速度制御による安全な原点出し
通常のスケッチでは電源投入時にサーボが最高速で回転しますが、生成AIに「指定した角速度(velocity)で動かす」機能を実装させたことで、超低速でゆっくりと回転させながら部品をはめる位置を確認できました。これにより、急な動作による部品の破損や怪我のリスクを排除しています。
2. PWMパルス幅の動的キャリブレーション
安価なサーボは個体ごとに特性が異なるため、AIに以下の調整機能を盛り込ませ、シリアルコマンドでリアルタイムに数値を書き換えられるようにしました。
- 0度の時のパルス幅(min_pulse)
- 180度の時のパルス幅(max_pulse)
この機能により、使用するサーボ(MG90S)の物理的な限界を見極めた上で、最適な角度でホーンを固定できました。
3. 将来のIK制御への応用
ここで実装した「1ms周期の補間による滑らかな移動」や「パルス幅の最適化」は、組み立てのためだけのツールではありません。この精密な制御基盤があるからこそ、後のステップで取り組む逆運動学(IK)を用いた座標制御において、カクツキのない滑らかな動作が可能になります。
ロボットアームの構造とジョイント位置の確認
組み立て完了後、制御の実装に向けて各ジョイント(関節)の役割を整理します。本当は組み立てる前に整理しておくべきなのですが、説明書がない状況では、組み立てたロボットアームを見ながら整理するしかありません。
このロボットアームは、以下の4つのサーボモータ(4DoF)で構成されています。
- J1(Base): 土台の水平回転。
- J2(Shoulder): アーム全体の前後・上下動。
- J3(Elbow): 肘にあたる部分の屈伸。
- J4(End Effector): 先端のグリッパー(爪)の開閉。
先端のEnd Effector(爪の部分)は、アームの姿勢が変わっても常に水平に保たれます。上の写真を見ると、平行四辺形の構造でアームが構成されているのがお分かりになるかと思いますが、この構造により水平に保たれます。
逆運動学(IK)の実装方針
このロボットの幾何学的な特徴は、J1, J2とJ3の動きによって先端の位置(XY座標)が決まる点にあります。構造がシンプルであるため、複雑な行列演算を用いずとも、 余弦定理(三角関数) を活用した平面幾何として逆運動学(IK)を容易に実装できそうです。
具体的には、目標とする座標からJ1の旋回角と、J2・J3の角度を逆算します。J4は「掴む・離す」という独立したアクションとして制御します。今後はこの計算モデルをベースに、カメラ画像から得た座標へアームを正確に移動させる「物理的な制御」に取り組んでいきます。
PWMサーボモータドライバのセットアップ
購入したPCA9685基板も、裏面に仕様が記載されているのみで詳細なマニュアルはありません。
基板裏面を確認すると、ターミナルブロックからの入力は逆極性保護が施されているようです。モーター駆動は大きな電流が流れるため、極性の間違いは致命的です。安全のため単3電池4本(6V)で駆動させます。
動作確認向け制御構成
以下の構成で、PCA9685を経由したサーバモータ制御の動作確認を行います。Mac(Arduino IDE2)からUSBシリアルでArduino UNOへコマンドを送り、I2C経由でPCA9685を叩く構成です。
まず、手元にあったSG90で動作確認を始めます。安全に動作するのが確認出来たら、ロボットに搭載されたMG90SへPCA9685の基板を接続します。
この構成は一時的な動作確認用ですが、後で、Macの部分をラズパイに置き換え、ロボット遠隔制御MCPサーバー機能を持たせる予定です。
以下が、この構成の写真です。
補足:この構成にした理由
Arduino単体でもサーボモータの制御は可能ですが、実機運用の「堅牢性」と「安全性」を重視し、あえてPCA9685を導入しました。
- 配線のスッキリ化:I2C(2線)だけで4個のサーボを制御できるため、配線をスッキリ化出来ます。通常ならブレッドボード上で複雑になりがちな電源共有や信号線の分岐が必要ありません。サーボをそのままPCA9685基板のピンに差し込むだけで配線が完了します。
- 電源分離による安全性(逆極性保護):サーボ駆動には大きな電流が必要なため、制御系とは別に単3電池4本(6V)から直接給電しています。基板上のターミナルブロックには逆極性保護が備わっており、電池のプラス・マイナスを逆につなぐといった、実機制作で起こりがちな焼損事故を未然に防げます。
また、この後、このロボットを遠隔制御するためのMCPサーバー機能をラズパイに持たせようと考えています。Arduino抜きで、ラズパイ+PCA9685といった構成も可能ですが、Linux OS(ラズパイ等)はバックグラウンド処理の影響で数ミリ秒の遅延(ジッタ)が生じがちです。Arduinoはハードウェアタイマーにより1ms周期の厳密な制御が可能です。 「高度な処理はラズパイ、滑らかな運動制御(反射)はArduino+PCA9685」という階層構造にすることで、カクツキのない安定した動作を実現出来ます。
テストプログラムの実装
Geminiが生成したPCA9685用コードを使用します。Adafruitの「PWM Servo Driver Library」をインストールすることで、スムーズにビルドできました。
PCA9685テストプログラム
#include <Wire.h>
#include <Adafruit_PWMServoDriver.h>
// Create the driver object using the default address 0x40
Adafruit_PWMServoDriver pwm = Adafruit_PWMServoDriver();
// Servo settings
#define SERVO_FREQ 50 // Analog servos run at ~50 Hz updates
// We use scaled integers (microseconds * 1000) for smooth, fast ISR math.
// 0 deg = 544us, 180 deg = 2400us.
int min_pulse = 544;
int max_pulse = 2400;
float us_per_deg = (2400 - 544) / 180.0; // ~10.311
float current_velocity = 60.0; // Degrees per second
long current_pos_scaled; // Current position in microseconds * 1000
long target_pos_scaled; // Target position in microseconds * 1000
long step_scaled; // Step size in microseconds * 1000 per tick
int current_channel = 0; // Current PWM channel (0-15)
unsigned long last_update_time = 0;
String inputBuffer = "";
void setup() {
Serial.begin(9600);
Serial.println("PCA9685 Servo Controller");
Serial.println("Send commands like 'rotation=90' or 'velocity=50'");
Serial.println("Calibration: 'min_pulse=544', 'max_pulse=2400'");
Serial.println("Channel: 'channel=0' (0-15)");
pwm.begin();
// The internal oscillator is around 27MHz
pwm.setOscillatorFrequency(27000000);
pwm.setPWMFreq(SERVO_FREQ);
// Initialize position at 90 degrees
float start_angle = 90.0;
long start_us = min_pulse + (long)(start_angle * us_per_deg);
current_pos_scaled = start_us * 1000;
target_pos_scaled = current_pos_scaled;
step_scaled = (long)(current_velocity * us_per_deg);
// Initial move
// Calculate the number of ticks (0-4095) corresponding to the start pulse width
uint16_t ticks = (uint16_t)(start_us * 4096.0 / (1000000.0 / SERVO_FREQ));
pwm.setPWM(current_channel, 0, ticks);
last_update_time = micros();
}
// --- Servo Update Function ---
// Called from loop() every 1000us (1000Hz)
// This function updates the servo position incrementally to achieve smooth velocity control.
void updateServoMovement() {
long current = current_pos_scaled;
long target = target_pos_scaled;
// If we are already at the target, do nothing
if (current == target) {
return;
}
long step = step_scaled;
// Move current position towards target by 'step' amount
if (current < target) {
current += step;
if (current > target) {
current = target;
current = target; // Prevent overshooting
}
} else {
current -= step;
if (current < target) {
current = target;
current = target; // Prevent undershooting
}
}
current_pos_scaled = current;
// Convert back to microseconds (divide by 1000)
int us = (int)(current / 1000);
// Convert microseconds to PCA9685 12-bit ticks (0-4095)
// Formula: ticks = us * 4096 / (1000000 / freq)
// (1000000 / freq) is the period in microseconds.
// 4096 is the resolution of the PCA9685.
uint16_t ticks = (uint16_t)(us * 4096.0 / (1000000.0 / SERVO_FREQ));
pwm.setPWM(current_channel, 0, ticks);
}
// --- Main Loop ---
// Handles servo updates and non-blocking serial communication.
void loop() {
// Check if it is time to update the servo (1000Hz)
// Using micros() ensures precise timing without blocking the processor
if (micros() - last_update_time >= 1000) {
last_update_time += 1000;
updateServoMovement();
}
// Process incoming serial data one character at a time
while (Serial.available() > 0) {
char c = Serial.read();
if (c == '\n') {
// End of command line, parse the buffer
inputBuffer.trim();
parseCommand(inputBuffer);
inputBuffer = "";
} else {
// Append character to buffer
inputBuffer += c;
}
}
}
// --- Command Parsing Function ---
// Parses commands in the format "key=value"
void parseCommand(String cmd) {
int eqIndex = cmd.indexOf('=');
if (eqIndex == -1) {
Serial.println("Invalid command format. Use 'key=value'.");
return;
}
String key = cmd.substring(0, eqIndex);
String valueStr = cmd.substring(eqIndex + 1);
float value = valueStr.toFloat();
if (key == "rotation") {
// Set target angle
// Constrain the angle to the valid servo range (0-180)
float angle = constrain(value, 0.0, 180.0);
// Calculate target in scaled microseconds
long new_target_scaled = (long)((min_pulse + angle * us_per_deg) * 1000);
target_pos_scaled = new_target_scaled;
Serial.print("Setting target rotation to: ");
Serial.println(angle);
} else if (key == "velocity") {
// Set movement speed
// Constrain velocity to a positive, reasonable value (e.g., 1 to 1000 deg/s)
current_velocity = constrain(value, 1.0, 1000.0);
// Calculate step size: (deg/sec * us/deg) / 1000Hz * 1000(scale)
// The /1000Hz and *1000(scale) cancel out.
long new_step_scaled = (long)(current_velocity * us_per_deg);
step_scaled = new_step_scaled;
Serial.print("Setting velocity to: ");
Serial.print(current_velocity);
Serial.println(" deg/s");
} else if (key == "min_pulse") {
// Calibration: Set minimum pulse width (0 degrees)
min_pulse = (int)value;
us_per_deg = (max_pulse - min_pulse) / 180.0;
// Recalculate velocity step based on new range
step_scaled = (long)(current_velocity * us_per_deg);
Serial.print("Min pulse set to: "); Serial.println(min_pulse);
} else if (key == "max_pulse") {
// Calibration: Set maximum pulse width (180 degrees)
max_pulse = (int)value;
us_per_deg = (max_pulse - min_pulse) / 180.0;
// Recalculate velocity step based on new range
step_scaled = (long)(current_velocity * us_per_deg);
Serial.print("Max pulse set to: "); Serial.println(max_pulse);
} else if (key == "channel") {
// Select active PWM channel
int ch = (int)value;
if (ch >= 0 && ch <= 15) {
current_channel = ch;
Serial.print("Selected channel: "); Serial.println(current_channel);
} else {
Serial.println("Invalid channel. Must be 0-15.");
}
} else {
Serial.print("Unknown command: ");
Serial.println(key);
}
}
シリアルモニタから「rotation=90」「velocity=10」といったコマンドを送り、意図通りに動作することを確認。生成AIがハードウェアを動かすコードを書き、それを人間が検証するプロセスそのものに「フィジカルAI」の片鱗を感じます。
波形観測
念のため、オシロスコープでPWM波形を確認しました。
周波数:50Hz(20ms周期)
0度〜180度でパルス幅が適切に変化していることを確認。
まとめと次のステップ
初回からなかなかのハードワークとなりましたが、無事に実機が動作する環境が整いました。
今回作成したジョイント構成(J1〜J4)に基づき、今後は逆運動学(IK)の実装へと進みます。余弦定理を用いた角度計算をArduinoで行うか、上位側で行うか検討しつつ、USBカメラやGemini Robotics-ERとの連携を目指します。
次回は、Arduino UNOベースのロボットコントローラの制作について詳解する予定です。









