ブログ - 最新エントリー

pythonでDXMIOと戯れる

カテゴリ : 
雑記
2025-12-14 11:10
DXMIOはGPIOやアナログ電圧測定、I2CやSPIによるセンサデータの取り込み、複数のUARTとDXLシリーズと同じI/Fを備えたシンプルなマイコンボードである。そこに本来なら外付けでも良いIMUを搭載しているのは、DXMIOが特定用途で使われる事が想定されているからに過ぎず、使わないのであればIMUはただの石に過ぎず。
IMUの性能に過大な期待をするのは禁物だが、今回は姿勢をクォータニオン(四元数)で出力するため、前バージョンのDXMIOのオイラー角出力の特異点に苛まれる事が無いので、その点を踏まえて少し具体例を紹介しておくことにした。

公開されているデモにはDXMIOを模擬した直方体の座標をクォータニオンを使って回転させ、matplotlibの3Dグラフ上に表示させるpythonのスクリプト(demo4.py)がある。必要最低限な構成なので、これ元に多少色気を出したGUIを実装してみる。

まずDXMIOには出荷時にDXL v2.0のプロトコルで通信するファームウェアが書き込まれている。PCにてIMUのデータを取得するにはそのプロトコルに従ったやりとりをする必要があるが、専用のライブラリを使ってしまえば簡単である。
from ctypes import *
from pyDXL import DXLProtocolV2
class TIMU(Structure):
_pack_ = 1
_fields_ = [
('acc', c_float * 3), ('gyro', c_float * 3), ('mag', c_float * 3),
('lia', c_float * 3), ('rv', c_float * 5), ('grv', c_float * 3),
('temp', c_int16) ]

dx = DXLProtocolV2('/dev/ttyUSB0', 1000000)
r = dx.Read(200, 108, sizeof(TIMU))
if r:
imu = TIMU.from_buffer_copy(r)
IMUの情報はアドレスとデータサイズが決められたコントロールテーブル上に配置されているので、ctypesのStructureを使ってIMU部分のデータ構造を模擬。pyDXLライブラリのReadを使ってDXMIOの200番地から108バイト分のIMUデータを変数へ一気に読み出し。この程度のコードでIMUの瞬時データをPCへ取り込める。

次にGUIだが、pythonで構成する方法は数多あるが、ここでは少ないコードで気張ったGUIが作れるPySideを使う事にする。PySideはQtをpythonから扱いやすくするライブラリとの事で、初見であってもAIに任せればそれなりの例を示してくれる筈なので横着に寄与できる。
demo4.pyでは固定値になっているCOMポートは、PCに装着されたCOMポートのリストから選択できるようQComboBoxを使用、DXLProtocolV2によるポートの開閉はQPushButtonでOPENとCLOSEボタンを作って対応、matplotlibのfigureはFigureCanvasQTAggで埋め込み。IDとボーレートは固定値のままだがデフォルトで使う前提。最終的に以下イメージのウィンドウを構成。


3DグラフにDXMIOの姿勢のみを表示させているのが寂しかったので、オマケで線形加速度(imu.lia)と重力(imu.grv)のベクトルをquiverを使って矢印描画する事にした。いずれも直値を元に矢印を描いてしまうと立方体の回転とは当然連動しないため、立方体と同様にクォータニオンで回転させた座標を使う。回転にかかる一切合切の演算はscipyのRotationにお任せ。

各コンポーネントの初期化処理が少々長くなってしまったが、最終的なコードはこちらに。
#!/usr/bin/python3

# matplotlibによる3D表示
# クォータニオンによる回転処理
# PySide6を使ったGUI

from PySide6.QtCore import Qt, Slot, QObject
from PySide6.QtWidgets import (QApplication, QMainWindow, QVBoxLayout, QHBoxLayout, QWidget, QComboBox, QLabel, QPushButton, QMessageBox)

from matplotlib.figure import Figure
from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg, NavigationToolbar2QT as NavigationToolbar
from matplotlib.animation import FuncAnimation
from mpl_toolkits.mplot3d.art3d import Poly3DCollection

import re
from ctypes import *
import numpy as np
from serial.tools.list_ports import comports
from scipy.spatial.transform import Rotation

from pyDXL import DXLProtocolV2

# コントロールテーブルのうちIMUの部分
class TIMU(Structure):
_pack_ = 1
_fields_ = [
('acc', c_float * 3), ('gyro', c_float * 3), ('mag', c_float * 3), ('lia', c_float * 3),
('rv', c_float * 5), ('grv', c_float * 3), ('temp', c_int16)
]

# PySide6のGUIのクラス
class MainWindow(QMainWindow):
# コンストラクタ
def __init__(self, *args, **kwargs):
super(MainWindow, self).__init__(*args, **kwargs)
self.dx = None

# タイトル設定
self.setWindowTitle('DXMIO')
# PORT SELECT AREA
layout_port = self.create_area_port()
# matplotlibのグラフのクラスオブジェクト
layout_plot = self.create_area_matplot()
# GUIレイアウト作成
layout = QVBoxLayout()
# PORT AREA
layout.addLayout(layout_port)
# MATPLOT AREA
layout.addWidget(layout_plot)
# matplotlibのツールバーを作成
toolbar = NavigationToolbar(layout_plot, self)
layout.addWidget(toolbar)

# GUI画面全体のレイアウトを作成
widget = QWidget()
widget.setLayout(layout)
self.setCentralWidget(widget)

# アニメーション開始
self.ani = FuncAnimation(self.fig, self.plot, interval=10, blit=False, cache_frame_data=False)

def sort_key_num(self, port):
match = re.search(r'\d+', port.device)
if match:
return (port.device[:match.start()], int(match.group()))
return (port.device, 0)

# PORT選択エリア
def create_area_port(self):
# LABEL
label = QLabel('COM PORT')
# Port名Combobox
self.combo = QComboBox()
self.combo.setFixedWidth(100)
# 検出したポート名を設定
ports = sorted(comports(), key=self.sort_key_num)
for port in ports:
self.combo.addItem(port.device)
# Openボタン
self.button_open = QPushButton('OPEN')
self.button_open.setFixedWidth(100)
self.button_open.clicked.connect(self.open)
# Closeボタン
self.button_close = QPushButton('CLOSE')
self.button_close.setFixedWidth(100)
self.button_close.clicked.connect(self.close)
self.button_close.setEnabled(False)
# レイアウトに設定
layout = QHBoxLayout()
layout.addWidget(label, 0)
layout.addWidget(self.combo, 1)
layout.addWidget(self.button_open, 2)
layout.addWidget(self.button_close, 3)
# QHBoxLayoutの左寄せ用
layout.addStretch()
return layout

# MATPLOTエリア
def create_area_matplot(self):
self.fig = Figure(figsize=(8, 8), dpi=100)
# MATPLOTの軸設定
self.ax = self.fig.add_subplot(111, projection='3d')
self.ax.set_aspect('equal')
self.ax.set_proj_type('persp', focal_length=.4)
graph_canvas = FigureCanvasQTAgg(self.fig)
return graph_canvas

# メッセージBOX表示
def message_box(self, msg):
msgbox = QMessageBox(self)
msgbox.setWindowTitle('ERROR')
msgbox.setText(msg)
msgbox.setIcon(QMessageBox.Icon.Critical)
msgbox.setDefaultButton(QMessageBox.Ok)
msgbox.exec()

# OPENボタン Callback
def open(self):
try:
portname = self.combo.currentText()
port = '\\\\.\\' + portname
self.dx = DXLProtocolV2(port, 1000000)
self.combo.setEnabled(False)
self.button_open.setEnabled(False)
self.button_close.setEnabled(True)
except:
self.dx = None
self.combo.setEnabled(True)
self.button_open.setEnabled(True)
self.button_close.setEnabled(False)
# エラーメッセージ
self.message_box(portname + 'がOpenできません')

# CLOSEボタン Callback
def close(self):
del self.dx
self.dx = None
self.combo.setEnabled(True)
self.button_open.setEnabled(True)
self.button_close.setEnabled(False)

# 表示更新
def update_plt(self, acc, grv, quat):
# ボックスの頂点座標を定義 (中心を原点とする)
# Vertex index: 0:(-1,-2,-.5), 1:(1,-2,-.5), ...
vertices = np.array([ [-1, -2, -.2], [1, -2, -.2], [1, 2, -.2], [-1, 2, -.2], [-1, -2, .2], [1, -2, .2], [1, 2, .2], [-1, 2, .2] ]) * 0.5

# 面を構成する頂点のインデックス
faces = [
[0, 1, 2, 3], # Bottom
[4, 5, 6, 7], # Top
[0, 1, 5, 4], # Front
[2, 3, 7, 6], # Back
[0, 3, 7, 4], # Left
[1, 2, 6, 5] # Right
]

# 現在のプロットを消去し軸ラベルと範囲を設定
self.ax.cla()
self.ax.set(xlim=(-1, 1), ylim=(-1, 1), zlim=(-1, 1))
self.ax.update({'xlabel':'x', 'ylabel':'y', 'zlabel':'z'})
np.set_printoptions(suppress=True, precision=4)

# ボックスをプロット
rotated_vertices = Rotation.from_quat(quat).apply(vertices)
# 回転した頂点を用いて面を形成
rotated_faces = [rotated_vertices[face] for face in faces]
self.ax.add_collection3d(Poly3DCollection(rotated_faces, facecolors='cyan', linewidths=1, edgecolors='r', alpha=.4))

# 加速度を矢印でプロット
rotated_acc = Rotation.from_quat(quat).apply(acc)
self.ax.quiver(0, 0, 0, rotated_acc[0], rotated_acc[1], rotated_acc[2], color='red', length=0.1, linewidth=2,arrow_length_ratio=0.1)

# 重力を矢印でプロット
rotated_grv = Rotation.from_quat(quat).apply(grv)
self.ax.quiver(0, 0, 0, rotated_grv[0], rotated_grv[1], rotated_grv[2], color='blue', length=0.1, linewidth=2,arrow_length_ratio=0.1)

self.ax.set_title(
'Quaternion: '+', '.join(f'{x:7.3f}' for x in quat)
+'\nAcceleation: '+', '.join(f'{x:7.3f}' for x in acc)
+'\nGravitiy: '+', '.join(f'{x:7.3f}' for x in grv)
)

# DXMIOからのIMUデータで画面更新
def plot(self, data):
if self.dx is not None:
try:
r = self.dx.Read(200, 108, sizeof(TIMU))
if r:
imu = TIMU.from_buffer_copy(r)
self.update_plt(np.ctypeslib.as_array(imu.lia), np.ctypeslib.as_array(imu.grv), np.ctypeslib.as_array(imu.rv)[:4])
except:
pass

if __name__ == '__main__':
# Qtアプリケーションの作成
app = QApplication()

# フォームを作成して表示
form = MainWindow()
form.show()

# 画面表示のためのループ
app.exec()
matplotlibのグラフ更新が遅いのは相変わらずだが、簡易的に姿勢を把握するだけならこれで十分。

なお紹介したコードは現時点で公開しているGCC Developer Lite用のWin64パックで実行できるように書いたため、ご自身の環境で試す場合はimport部分を元に必要なライブラリを自力で追加してもらう他ないので悪しからず。
気が向いたらこのネタをもうちょっと引っ張るかも知れませぬ。

技術

分配基板

カテゴリ : 
新商品
2025-6-27 15:50
DXSharingBoardはACアダプタで給電する前提だが、実際の運用となるとACアダプタの容量では不足する事もあるし、わざわざ電源元からプラグへ変換して給電するのもイマイチ。
ということで電源コネクタの容量を増やしたシンプルな分配基板を作成。部品を実装しない廉価キットも合わせて近々提供予定。


技術

自爆防止

カテゴリ : 
雑記
2024-10-10 10:40
車もコンピュータのアシストなしではエンジンすらかからない世で、何もかも人任せというのはあり得ず。
形態はラジコン型であっても、こちらもコンピュータが介在していることには違いは無く。しかし受信機のパルスを取り込んでモータの指令電圧に変換しているだけに過ぎず、スピコンと同等の事をしているだけ。
土俵センサで多少なりとも自爆を回避してくれさえすれば、人力ポーリングの負荷は軽減できるだろう。

技術

出稽古

カテゴリ : 
雑記
2024-10-8 11:20
あっという間に大会まで1週間を切り、季節も長袖の出番に。
大したフォローもできない中、今日も今日とて出稽古しながら切磋琢磨。
スピードもだんだん本番仕様に。

技術

UD.....

カテゴリ : 
雑記
2024-9-13 8:40
いつまで続くの灼熱の世。
台風のせい(おかげ)で1ヶ月の熟成期間ができてしまった。

テスターの松本殿も忙しい中お付き合い。
パラメータは緩いままだが、操縦感覚をつかむべく練習。
このサイズで150W 30Vオーバー 起動電流80A越えなモータ2本の運用はなかなかシビれるわん。
耐性は何もかもブスバー頼み。

出稽古しながら微調整に勤しんでおられる。

技術

UD....

カテゴリ : 
雑記
2024-8-25 19:50
外は暑いし実装は灼熱、動いてしまえばアンプは冷え冷え。
MPU変わるとこんなに違うか?
とにかくあと1週間。



技術

UD...

カテゴリ : 
雑記
2024-8-19 15:40
今更だが最後のご奉公のつもりでお絵かき。
基本的には前作と大差無く、気まぐれでRasPiを載せてみた。
が、火を入れたら瞬殺orz。


技術

DXL Yシリーズ試食3

カテゴリ : 
雑記
2024-7-2 16:40
またまた前回に引き続きYシリーズをお試し。

負荷をつなげていない状態で評価してもあまり意味は無いのだが、パワーが有り余って危険なのでご了承のほど。
その代わりと言っては何だが、最大加速度と急峻な指令値の変更を伴う運転を少し長めに行いつつ、温度を含めて観察しておいた。
ちなみに後ろで回っているCyberGearは比較用。


技術

DXL Yシリーズ試食2

カテゴリ : 
雑記
2024-6-28 17:50
前回に引き続きYシリーズをお試し。

ギア無しのモデルは台車の足回りの駆動に使うケースもあるかという事で、速度制御もやっておく。見てくれは先のコードと大差無く、動作モードと指令先が違うだけ。こちらも例に漏れず扱う数値が大きい。
import time
from pyDXL import *
dx = DXLProtocolV2('/dev/ttyAMA0', 57600, 0.05)
ID = 1
dx.Write8(ID, 512, 0)
if input('Clear? (y/N) ') == 'y':
dx.TxPacket(ID, DXLProtocolV2.INST_CLEAR, (0x01, 0x44, 0x58, 0x4c, 0x22))
dx.RxPacket(timeout = 5.0)
time.sleep(1)
dx.Write8(ID, 33, 1)
dx.Write8(ID, 512, 1)
for i in range(-550000, 550000, 550000 >> 4):
dx.Write32(ID, 528, i)
time.sleep(0.5)
dx.Write8(ID, 512, 0)
YM080-230-R099-RHもつなぎ、テキトウに位置決め制御と速度制御を行わせた動画を。簡単に動かすにしても机に置いただけだと暴れまくって危険なので、重しにくくりつけておいた。


技術

DXL Yシリーズ試食1

カテゴリ : 
雑記
2024-6-24 9:10
DXLシリーズはモデルが大きく変わると運用方法が変わってしまう。せめてモデルが違っていても同じアイテム名のアドレス程度は同じであって欲しいのだが、Yシリーズも例に漏れず他のモデルとの互換性は低い。
毎回ドキュメントを読み返すのも何なのでDX2LIBを真似てModel Noを元にグループ化し、一部の共通するアイテムのアドレスを引っ張り出せる簡単なクラスを作成

次に電源だが、既存との互換性は一切ない。それなりに大きいターミナルブロックが2箇所設けられているが、単独で消費する電流程度しかまかなえないので、DXLをまたぎながらマルチドロップ接続する事は避けた方が良さそうだ。またYM70とYM80ではターミナルブロックのサイズが異なるので、使い回しができない。
通信はRS-485という事で、電気的に既存のDXL用のI/Fが使える。シグナルGNDは電源と共通で、D+とD-のみの1.25mmピッチ2ピンのコネクタが用意されている。筐体のサイズに対してコネクタが華奢なのと、自前でツイストシールド線を使った場合のシールド設置処置が少々面倒。


今回は諸々の電気的な不都合を無視した上で4ピンのEHコネクタに変換するケーブルを作成し、例のRasPi Zero 2W + キャリアボードに接続。


Pingを叩いたら応答するので電源と通信はOK。しかしLEDが点滅し続ける状態でトルクイネーブルができない。コントロールテーブルばかりを眺めていたので見逃していたが、e-manualの冒頭に記載されているバックアップバッテリに関する注意書きを読み飛ばしていた。筐体に内蔵されているバックアップバッテリのリード線が外れているので接続。


しかしまだ動かない。Clear Errorを行わないといけないようだ。特殊なパケットだが書いてある通りのバイト列を送信するのみ、DXL Wizardを使うのであればその操作方法に従うまで。
 dx.TxPacket(ID, DXLProtocolV2.INST_CLEAR, (0x01, 0x44, 0x58, 0x4c, 0x22))
 dx.RxPacket(timeout = 5.0)
なおError Clearはステータスパケットの返答までの時間が4秒程度と長かったり、その処理の直後はインストラクションパケットを受け取らない期間があったりと変な癖が見つかったので、pyDXL.pyのRead関数にtimeoutの引数を追加しておいた。

ようやく下準備が整ったところで、まずは位置決め制御を行ってみる。その所作は他のモデルと大差無く、トルクディスエーブル→動作モード設定→トルクイネーブル→目標位置へ任意の値の書き込み、といった段取り。今回試したYM070-210-M001-RHの場合、360度の範囲で動かそうとすると最大分解能が19ビットとかなり大きい数値を扱う事になる。
import time
from pyDXL import *
dx = DXLProtocolV2('/dev/ttyAMA0', 57600, 0.05)
ID = 1
dx.Write8(ID, 512, 0)
if input('Clear? (y/N) ') == 'y':
dx.TxPacket(ID, DXLProtocolV2.INST_CLEAR, (0x01, 0x44, 0x58, 0x4c, 0x22))
dx.RxPacket(timeout = 5.0)
time.sleep(1)
dx.Write8(ID, 33, 3)
dx.Write8(ID, 512, 1)
for i in range(-262144, 262144, 512):
dx.Write32(ID, 532, i)
dx.Write8(ID, 512, 0)

といったところで1時間が経過したので一旦休憩、続きは次回に。

技術