ARマーカーを貼り付けたラックから、マイクロピペットのチップをロボットアームで取る


概要

手抜きなハード設計&ローコード、ロボットアームによるマイクロピペットのチップ装着というタスクを、わりとロバストに実装できるかもしれないことを示します。うまく作り込めば、予算20万円くらいで、使いやすい自動ピペットマシンを作れそうです。
(装着確認はGPT-4Vなどで可能)

はじめに

ロボットアームを使ってマイクロピペットを操作するにあたり、チップの装着が一つの課題になります。正確にピペットを刺さないとチップを装着できないため、予めチップラックを完全固定し、座標をソフトウェア上に記憶させるようなアプローチが一般的です。

ただ、筆者は面倒くさがりなので、そういったハードの作り込みをすることに苦痛を感じてしまうタイプです。
そこで、ハードの作り込みはせず、ソフトウェア側で座標制御を行ってみました。実際の動画は以下の通り。

関連研究として、例えば理研から、deep learningでチップを認識して装着する手法が発表されていました。

ただ、筆者は面倒くさがりなので、YOLOなどの深層学習モデル用のデータセットを作る作業にも苦痛を感じるようになってしまいました。
(ガラス瓶の口を認識させたことはあります。下記動画)

また、特化モデル系は、環境光やチップ、ラック等の変化に意外と弱かったりする課題もあります。また、タスク毎にモデルを作る必要があり、ラックの穴、チップの装着確認、など、ラボオートメーションの細々とした作業を自動化するにはかなりの人的コストがかかります。

将来的にはLLMを載せた基盤モデル(たとえばgoogleのrt2)が最適解になる気がしていますが、まだ筆者には使いこなせないので、座標認識にARマーカーを使うことにしました※。

※正確には、以下のdiscordサーバーで、arマーカーをオススメされたので、その言葉を信じてやってみた次第です。


環境

ハードウェア(主に使うもの)

  • ロボットアーム

    • XYZ座標を制御できる装置なら、何でも原理的には何でもOKです。

      • 本記事では、東北大の高石先生に作っていただいた自家製のマシンを使っています(Opentrons的なマシン)。

  • USBカメラ

    • 以下のカメラを使いました

    • 選択理由

      • 四角形なので設置しやすい

      • 1/4ネジの穴があって固定しやすい(カメラの三脚に付ける用のネジ穴)

      • 画素数が高い

      • オートフォーカス

    • 実際に使った感想

      • 基本的にはOK

      • Arucoマーカーではなく、他の場所にフォーカスをあわせに行くことが多くて困った (このカメラに限らず、問題は起きるかも?)

        • マーカーがピンボケして認識できなくなる

  • チップラック

    • 3Dプリンタのモデルを改造して、Arucoマーカーを貼りました

    • 公開したいところですが、オリジナルモデルの著作権の問題があるので、配布できません

ソフトウェア

Pythonを使いました。
重要ライブラリは、

pip install opencv-contrib-python==4.7.0.68

です。 普通のopencvはuninstallしてからinstallする必要があります。また、opencvはバージョン毎に関数名などがコロコロ変わるので、注意が必要です。

実装

コードを抜粋して貼っていきます。

usbカメラのキャリブレーション

カメラの画像は歪んでいるので、chess boardでキャリブレーションしておきます。

arucoマーカーを読み取るクラス

usbカメラからframeの読み取り

import cv2


class USBCamera:
    def __init__(self, camera_id,
                 ):
        self.camera_id = camera_id
        self.cap = cv2.VideoCapture(self.camera_id)

        if not self.cap.isOpened():
            raise IOError("Cannot open webcam")

    def get_frame(self, ):
        ret, frame = self.cap.read()

        if not ret:
            raise IOError("Cannot read frame")

        frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
        return frame


Arucoマーカーの検出とカメラの相対位置の読み取り

from cv2 import aruco
import numpy as np
import cv2

class ArucoDetect:
    def __init__(self,
                 marker_length=1.8,
                 mtx_path="",
                dist_path="",
                 ) -> None:
        self.aruco_dict = aruco.getPredefinedDictionary(aruco.DICT_4X4_50)
        self.parameters = aruco.DetectorParameters()
        self.marker_length=marker_length

        if mtx_path!="" and dist_path!="":
            self.mtx=np.load(mtx_path)
            self.dist=np.load(dist_path)

    def detect_aruco(self, frame):
        corners, ids, rejectedImgPoints = aruco.detectMarkers(
            frame, self.aruco_dict, parameters=self.parameters)
        return corners, ids  # , rejectedImgPoints

    def estimate_position(self,camera):
        frame=camera.get_frame()

        #frame = cv2.detailEnhance(frame, sigma_s=10, sigma_r=0.15)
        corners,ids=self.detect_aruco(frame)
        rvec, tvec, _ = cv2.aruco.estimatePoseSingleMarkers(corners,self.marker_length,
                                                            self.mtx, self.dist)

        return frame,corners,ids,rvec,tvec

マーカーの読み取り

以下、main部分の処理です。
クラスの初期化

camera=USBCamera("COM3") #com portなどを設定
aruco_detector=ArucoDetect(marker_length=1.5,
                           mtx_path='dat/mtx_elp.npy',
                           #mtx_path='dat/mtx.npy',
                           dist_path='dat/dist_elp.npy'
                           #dist_path='dat/dist.npy'
                           
                           )

Arucoマーカーの読み取り


frame,corners,ids,rvec,tvec=aruco_detector.estimate_position(camera)
cv2.aruco.drawDetectedMarkers(frame, corners, ids, (0,255,255))
clear_output(wait=True)
plt.imshow(frame)
plt.axis('off')
plt.show()

マーカーからXX cm離れた場所の計算

今回のターゲットはマーカーそのもののではなく、チップの座標です。なので、マーカーからXX cm離れた場所の座標を得る必要があります。
回転行列などを使うようです。ChatGPTに聞いて書いたコードです。

while True:

    frame,corners,ids,rvec,tvec=aruco_detector.estimate_position(camera)
    plt.figure()
    cv2.aruco.drawDetectedMarkers(frame, corners, ids, (0,255,255))

    world_point = np.zeros((3, 1))
    try:
        cv2.drawFrameAxes(frame, aruco_detector.mtx, aruco_detector.dist, rvec, tvec, 1)
        # 各マーカーに対する処理
        for i in range(len(ids)):
            # マーカーから X cm 右の点を定義
            local_point = np.array([[2.65], [0.65], [0]], dtype=np.float32)  # 単位は cm
            
            # 画像座標への変換
            img_point, _ = cv2.projectPoints(local_point, rvec[i], tvec[i], aruco_detector.mtx, aruco_detector.dist)
            
            # 点を画像上に描画
            point = tuple(img_point[0][0].astype(int))
            cv2.circle(frame, point, 5, (0, 0, 255), -1)

            # 回転行列への変換
            R, _ = cv2.Rodrigues(rvec[i])
            # ローカル座標をワールド座標系に変換
            world_point = R @ local_point + tvec[i].reshape(3, 1)

    except Exception as e:
        print(e)
        pass
    clear_output(wait=True)
    plt.imshow(frame)
    plt.axis('off')
    plt.show()

ちょっとわかりにくいですが、画面上の青い点が、計算された座標です。


アームの移動

チップの位置は、カメラに対する相対座標としてworld_point変数に格納されています。
あとは、アームの座標系に適当に換算してフィードバックを回していけば、チップの上にマイクロピペットが移動します。

import time
import random
while True:

    frame,corners,ids,rvec,tvec=aruco_detector.estimate_position(camera)
    plt.figure()
    cv2.aruco.drawDetectedMarkers(frame, corners, ids, (0,255,255))

    world_point = np.zeros((3, 1))
    try:
        cv2.drawFrameAxes(frame, aruco_detector.mtx, aruco_detector.dist, rvec, tvec, 1)
        # 各マーカーに対する処理
        for i in range(len(ids)):
            # マーカーから 1 cm 右の点を定義
            local_point = np.array([[2.65, 0.65, 0]], dtype=np.float32)  # 単位は cm
            local_point = np.array([[2.65], [0.65], [0]], dtype=np.float32)  # 単位は cm
            
            # 画像座標への変換
            img_point, _ = cv2.projectPoints(local_point, rvec[i], tvec[i], aruco_detector.mtx, aruco_detector.dist)
            
            # 点を画像上に描画
            point = tuple(img_point[0][0].astype(int))
            cv2.circle(frame, point, 5, (0, 0, 255), -1)

            # 回転行列への変換
            R, _ = cv2.Rodrigues(rvec[i])
            # ローカル座標をワールド座標系に変換
            world_point = R @ local_point + tvec[i].reshape(3, 1)

    except Exception as e:
        print(e)
        pass
    clear_output(wait=True)
    plt.imshow(frame)
    plt.axis('off')
    plt.show()

    #move
    if world_point[0][0]!=0:
        offset=(-0.7,6.5) #カメラやマイクロピペットの位置の補正
        x=arm.x
        y=arm.y
        arm.set_xyz(y=y+world_point[0][0]*1+offset[0],x=x-world_point[1][0]*1+offset[1],z=60)
        print(world_point[0][0],world_point[1][0])
        
        new_x=arm.x
        new_y=arm.y


        #移動しなくなったらループを抜ける
        if abs(new_x-x)<0.04 and abs(new_y-y)<0.04:
            break
 
    else:
        offset=(0,0)

        arm.set_z(random.randint(50,60))
   
    
    #time.sleep(0.3)
        

#チップを下げる
arm.set_z(130)
arm.set_z(100)

ここで、armはロボットを管理するクラスで、set_xyzはxyz(zは高さ)までアームを動かす関数です。アーム座標はarm.x, arm.y, arm.zに格納されています。

補足

  • 使用するロボットアームによって、このあたりの実装は変わるはずです。

  • フィードバックループはかなり雑です。きちんと書けば、もっと早く収束するはずです

  • ARマーカーではなく背景にピントが合ってしまうことが多々ありました。

    • 対応策として、適当にアームの高さを変えて再挑戦する加えています

  • 今回は3Dプリンタ/CNC式のマシンを使ったので、アーム―ピペットーカメラ座標系の変換が簡単でした(offsetを定数として定義)

  • 普通のロボットアームの場合は、アームがXY平面上で回転するので、アーム―ピペットーカメラ座標系の変換に三角関数が必要になると思います。

動作シーンをもう一度。

装着確認

APIではまだ実装してませんが、マルチモーダルLLMなら、チップ装着の有無くらいは判定できるようです

ChatGPTの判定は以下の通り。

ついているシーン


付ける前のシーン

背景と被ってて、人間でもややわかりにくいシチュエーションであるにも関わらず、きちんと判定できています。

おわりに

手抜きなハード設計&ローコードで、ロボットアームによるマイクロピペットのチップ装着というタスクを、わりとロバストに実装可能!?なことがわかりました。

最近のOpentronsはマイクロピペット付きで200万円以上するようですが、Dobot Magician + 電子ピペット(ボタンを改造して電子制御できるようにハック) + 今回のようなソフトウェアなら、20万円くらいで、使いやすい自動ピペットマシンを作れそうです。 マルチモーダルLLMも便利ですね。

この記事が気に入ったらサポートをしてみませんか?