OpenMV電動顕微鏡 精度0.0001mm
Singtown Technology OpenMVビデオチュートリアル - 精度0.0001mmのOpenMV電動顕微鏡を作ってみた!
OpenFlexure顕微鏡は、オープンソースかつ低コストで、モジュール化された顕微鏡であり、組み立て・操作・改造が容易であることを目指して設計されています。このプロジェクトは、教育、研究、そしてリソースの限られた環境向けに、従来の実験室用顕微鏡に代わる柔軟でアクセスしやすい選択肢を提供することを目的としています。Singtown Technologyは、この顕微鏡にOpenMVへの対応を追加しました。
OpenFlexure顕微鏡には、以下のような利点があります。
(1)最も特徴的なのはプラットフォームの構造です。PLA材料のしなやかさを利用しており、摩擦や振動の影響を受けにくく、組み立てが非常に簡単です。
(2)精度が非常に高く、ステッピングモーターの1ステップは100ナノメートル未満で、これは赤血球の1/100に相当します。
(3)もう一つの利点は、3Dプリントのコストが比較的低いことです。
この顕微鏡の構造で最も優れている点は、PLA材料のしなやかさを利用しており、本体が一体成形で3Dプリントされていることです。プラットフォームの移動には摩擦がほとんどなく、精度が非常に高いため、100ナノメートルの精度を実現できます。既製の顕微鏡は制約が大きいため使用せず、OpenFlexure顕微鏡の設計を参考にしながら改造を加え、光学系全体を独自に設計しました。
私たちは、OpenFlexure顕微鏡をOpenMVに適合させるために以下の改造を行いました。主な変更点は4つあります。
(1)まず、OpenMVカメラの感光素子に合わせて鏡筒を改造しました。特筆すべきは、ここに1枚の筒レンズが使われている点で、これには2つの役割があります。1つは対物レンズの焦点距離を短くすること、もう1つはNA(開口数)を調整して画像をより明るくすることです。
(2)2つ目は、底部のドロワー部分の設計を改良し、ボタンとステッピングモーター用のモータードライバー基板を追加したことです。これにより、顕微鏡の移動とフォーカスを精密に制御できるようになりました。
(3)3つ目は、OpenMV用に新しいステッピングモータードライバー基板を設計したことです。これはI2C経由で4つのステッピングモーターを制御できます。
(4)最後に、OpenMV用のLCDディスプレイを追加し、独立した電源供給を実現するための全体設計を行いました。
全体構造
全体構造における主な変更点は、従来のRaspberry Piベースの構成をOpenMV構成に置き換え、ボタンとLCDディスプレイを新たに追加し、バッテリーによる独立した電源供給を採用することで、単体動作を実現したことです。
顕微鏡全体は3Dプリント技術で製作されています。低充填率・薄肉厚の3Dプリント造形物が持つ非常に高いしなやかさを利用しており、顕微鏡には複雑な機械構造は設計されておらず、非常に巧妙な方法でステージとレンズの移動を実現しています。この部分については後述で詳しく説明します。

ステージと鏡筒の移動
この部分では、OpenFlexureの設計方式が、特定の充填率・厚さのパラメータにおいて3Dプリント部品が持つ大きなしなやかさを利用し、非常に巧妙な構造を設計することで、ステージの平行移動を実現しています。

ステージの移動を実現する中核構造は、下図に示すようなはしご状の構造です。この構造の内部四辺形が持つ不安定性こそが、ステージ移動を実現する核心です。この構造を3Dプリンターで出力する際は、中間の2段の横バーを確実にプリントできることが必須です。この横バーが構造に十分なしなやかさを与える鍵となるため、3Dプリンターの性能にはある程度の要求があります。

ステージの移動について、X軸を例に説明します。顕微鏡本体のプリントが完了していることを前提に、ステージをX軸方向に移動させるには、1組のネジと1本の輪ゴムだけで実現できます。ネジは大歯車とはしご構造のトレイをつなぐように通されており、大歯車は後段でステッピングモーターによって駆動されます。輪ゴムも同様にはしご構造のトレイに接続されており、ネジとは逆方向の力を与えます。ネジの取り付け方法と輪ゴムの取り付け方法は、それぞれ以下の2枚の図で示されています。

ネジと輪ゴムは、それぞれステージをX軸方向に平行移動させるための2つの力を発生させます。ステッピングモーターが大歯車を時計回りに回転させると、ネジが締まり、はしご構造のトレイが持ち上げられ、はしごが左に偏移することで、ステージがX軸正方向へ移動します。逆に、ステッピングモーターが大歯車を反時計回りに回転させると、ネジが緩み、輪ゴムの張力によってはしご構造のトレイが下方向に引っ張られ、はしごが右に偏移することで、ステージがX軸負方向へ移動します。
ネジを締めた状態

初期状態

ネジを緩めた状態

Y軸方向の移動も同様の仕組みです。
鏡筒の上下移動にも同じ方式が使われています。ネジを締めるとトレイが持ち上げられ、鏡筒が下方向に移動し、ネジを緩めると輪ゴムによってトレイが下方向に引っ張られ、鏡筒が上方向に移動します。
鏡筒の構成
鏡筒については、OpenMVカメラの感光素子に合わせて一部の改造を行いました。OpenFlexure顕微鏡の元の設計ではRaspberry Pi用のarducam_b0196カメラが使用されていましたが、そのサイズはOpenMVのレンズに適合しないため、OpenSCADのコードを修正し、OpenMVのレンズに対応させました。
元のレンズ:

OpenMVのレンズ:

鏡筒には凸レンズ、感光素子、対物レンズが取り付けられます。
凸レンズ:

OpenMVの感光素子:

対物レンズ:

ステッピングモーターの制御
今後ボタンとLCDディスプレイを追加する必要があること、また28BYJ-48ステッピングモーターは専用のステッピングモータードライバーを使わなくても制御できることを考慮し、ここではPCA9555BSチップを用いてIOを拡張し、I2C通信を採用しました。ステッピングモーターの駆動にはULN2803LVSダーリントンアレイを使用しています。OpenMVはI2C経由で駆動パターンをPCA9555BSに送信し、PCA9555BSは対応するIOからHigh/Lowの電圧を出力してダーリントンアレイを駆動し、ステッピングモーターを動かします。
OpenMVはソフトウェアI2Cとして設定されており、P4がSCL、P5がSDAです。
i2c = SoftI2C(scl=Pin("P4"), sda=Pin("P5"))
PCA9555BSのアドレスは0x20で、すべてのIOは出力モードに初期化されます。
pca = PCA9555(i2c, address = 0x20)
for i in range(16):
pca.outputPins(i)
左から右へ順方向にステップパターンを送信するとステッピングモーターは正転し、右から左へ逆方向に送信すると逆転します。
[(1,0,0,0),(1,1,0,0),(0,1,0,0),(0,1,1,0),(0,0,1,0),(0,0,1,1),(0,0,0,1),(1,0,0,1),]
ステッピングモーター拡張基板:

ステッピングモーター拡張基板の回路図:

ボタンのスキャン
ボタンは、別のPCA9555BSで拡張されたIOを使って読み取ります。こちらもOpenMVのソフトウェアI2Cを使用し、P4がSCL、P5がSDAです。ただし、ボタン用のPCA9555BSのアドレスは0x21で、IOは入力モードに設定されます。
pca2 = PCA9555(i2c, address = 0x21)
for i in range(16):
pca2.inputPins(i)
ボタン拡張基板:

ボタン拡張基板の回路図:

PCBトレイ
顕微鏡の元の設計ではRaspberry Pi用のトレイが使われており、スイッチ、ディスプレイ、ボタンが存在しないため、顕微鏡単体では動作できませんでした。そこでトレイを設計し直し、OpenMVに適合させるとともに、ボタン用の開口部、OpenMVのUSBデバッグポート、スイッチ用のスロットを追加しました。
元のトレイ:

OpenMV用トレイ:

PCBをトレイに取り付け、LCDディスプレイへのジャンパー線を接続し、バッテリーを取り付けてから、ベースに収めます。
正面図:

上面図:

側面図:

LCDディスプレイ
OpenMV純正のLCDディスプレイを使用します。ディスプレイとOpenMVはジャンパー線で接続され、ディスプレイは顕微鏡ベースの外側に固定されます。
ベース:

LCDディスプレイ:

LCDディスプレイに画像全体を表示させるため、以下のコードを使って元の画像を回転・スケーリングし、ディスプレイの解像度に合わせています。
lcd.write(img.copy(x_scale=0.25, y_scale=0.267, hint=image.ROTATE_90))
顕微鏡の撮影例
スマートフォン画面のRGBピクセル配列:

線虫のプレパラート:

カエルの血液塗抹標本:

ゾウリムシのプレパラート:

OpenMV側の全コード
main.py
from machine import SoftI2C, Pin
from pca9555 import PCA9555
import csi, image, time
import display
csi0 = csi.CSI()
csi0.reset()
csi0.pixformat(csi.RGB565)
csi0.framesize(csi.VGA)
csi0.snapshot(time = 2000)
clock = time.clock()
i2c=SoftI2C(scl=Pin("P4"),sda=Pin("P5"))
#print(i2c.scan())
pca=PCA9555(i2c, address = 0x20)
pca2=PCA9555(i2c, address = 0x21)
for i in range(16):
pca.outputPins(i)
for i in range(16):
pca2.inputPins(i)
thresholds = [
(0, 34, -128, 127, -128, 127),
]#赤血球
thresholds2 = [
(0, 45, -128, 127, -128, 127),
]#ゾウリムシ
thresholds3 = [
(0, 48, -128, 127, -128, 127),
]#ミドリムシ
class Motor():
def __init__(self, motor_id):
self.step_list = [(1,0,0,0),(1,1,0,0),(0,1,0,0),(0,1,1,0),(0,0,1,0),(0,0,1,1),(0,0,0,1),(1,0,0,1)]
self.state_index = 0
if motor_id == 1:
self.pins = [0,1,2,3]
elif motor_id == 2:
self.pins = [4,5,6,7]
elif motor_id == 3:
self.pins = [8,9,10,11]
elif motor_id == 4:
self.pins = [12,13,14,15]
def step(self, inverse=False):
if inverse:
self.state_index -= 1
if self.state_index == -1:
self.state_index = 7
else:
self.state_index += 1
if self.state_index == 8:
self.state_index = 0
for i in range(4):
pca.writePin(self.pins[i], self.step_list[self.state_index][i])
def steps(self, n, delay=10):
inverse = False
if n > 0:
inverse = True
for i in range(abs(n)):
self.step(inverse)
time.sleep_ms(1)
mz = Motor(1)
#m2 = Motor(2)
mx = Motor(3)
my = Motor(4)
def key_read():
if pca2.readPin(0)==0:
my.steps(50)
# time.sleep_ms(10)
elif pca2.readPin(1)==0:
mx.steps(50)
# time.sleep_ms(10)
elif pca2.readPin(2)==0:
my.steps(-50)
# time.sleep_ms(10)
elif pca2.readPin(3)==0:
mx.steps(-50)
# time.sleep_ms(10)
elif pca2.readPin(4)==0:
mz.steps(50)
# time.sleep_ms(10)
elif pca2.readPin(5)==0:
mz.steps(-50)
cell_num = 0
photo_num = 0
step_num = 0
sum_num = 0
avg_num = 0
lcd = display.SPIDisplay()
while(True):
key_read()
clock.tick()
img = csi0.snapshot()
# strs = str(photo_num) +".jpg"
# img.save(strs)
# photo_num=photo_num+1
# for blob in img.find_blobs(thresholds, pixels_threshold=20, area_threshold=0,merge=0): #赤血球
# if blob.elongation > 0:
# img.draw_cross((blob.cx, blob.cy),size=9)
# cell_num=cell_num+1
# for blob in img.find_blobs(thresholds2, pixels_threshold=200, area_threshold=200,merge=0): #ゾウリムシ
# if blob.elongation > 0:
# img.draw_cross((blob.cx, blob.cy))
# cell_num=cell_num+1
# for blob in img.find_blobs(thresholds3, pixels_threshold=70, area_threshold=70,merge=0): #ミドリムシ
# if blob.elongation > 0:
# img.draw_cross((blob.cx, blob.cy))
# cell_num=cell_num+1
sum_num=sum_num+cell_num
cell_num=0
step_num=step_num+1
if step_num == 20:
avg_num=sum_num/step_num
step_num=0
sum_num=0
if round(avg_num)!=0:
print(round(avg_num))
lcd.write(img.copy(x_scale=0.25,y_scale =0.267,hint=image.ROTATE_90)) # 画像を撮影してディスプレイに表示する。
# print(clock.fps())
microscope.py
from machine import SoftI2C, Pin
from pca9555 import PCA9555
import time
i2c=SoftI2C(scl=Pin("P4"),sda=Pin("P5"))
print(i2c.scan())
pca=PCA9555(i2c, address = 0x20)
for i in range(16):
pca.outputPins(i)
class Motor():
def __init__(self, motor_id):
self.step_list = [(1,0,0,0),(1,1,0,0),(0,1,0,0),(0,1,1,0),(0,0,1,0),(0,0,1,1),(0,0,0,1),(1,0,0,1)]
self.state_index = 0
if motor_id == 1:
self.pins = [0,1,2,3]
elif motor_id == 2:
self.pins = [4,5,6,7]
elif motor_id == 3:
self.pins = [8,9,10,11]
elif motor_id == 4:
self.pins = [12,13,14,15]
def step(self, inverse=False):
if inverse:
self.state_index -= 1
if self.state_index == -1:
self.state_index = 7
else:
self.state_index += 1
if self.state_index == 8:
self.state_index = 0
for i in range(4):
pca.writePin(self.pins[i], self.step_list[self.state_index][i])
def steps(self, n, delay=10):
inverse = False
if n > 0:
inverse = True
for i in range(abs(n)):
self.step(inverse)
time.sleep_ms(10)
m1 = Motor(1)
while True:
m1.steps(10)
m1.steps(-10)
pca9555.py
from machine import SoftI2C, Pin
import time
InputPort0 = 0x00
InputPort1 = 0x01
OutputPort0 = 0x02
OutputPort1 = 0x03
PolInversionPort0 = 0x04
PolInversionPort1 = 0x05
ConfigPort0 = 0x06
ConfigPort1 = 0x07
#############################################################################
class PCA9555:
"""PCA955 Driver
16Bit IO extender
i2c communication
"""
def __init__(self, i2cBus, address=0x20):
"""
Args:
i2cBus: SoftI2C(Pin(*SCLpin*),Pin(*SDApin*))
address: i2c address in hex (0x20 by default)
"""
self.i2c = i2cBus
self.address = address
self.pinStats=[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]
self.pinValues=[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]
def inputPins(self,inputPin):
"""
Args:
inputPin(int): 0-7 = IO0_0 - IO0_7 ; 8-15 = IO1_0-IO1_7
"""
self.pinStats[inputPin]=1
stats=0
if inputPin <= 7:
for i in range(8):
stats+=self.pinStats[i]<<i
stats=int(hex(stats),0)
self.i2c.writeto_mem(self.address,ConfigPort0,bytes([stats]))
else:
for i in range(8):
stats+=self.pinStats[i+8]<<i
stats=int(hex(stats),0)
self.i2c.writeto_mem(self.address,ConfigPort1,bytes([stats]))
def outputPins(self,outputPin):
"""
Args:
outputPin(int): 0-7 = IO0_0 - IO0_7 ; 8-15 = IO1_0-IO1_7
"""
self.pinStats[outputPin]=0
stats=0
if outputPin <= 7:
for i in range(8):
stats+=self.pinStats[i]<<i
stats=int(hex(stats),0)
self.i2c.writeto_mem(self.address,ConfigPort0,bytes([stats]))
else:
for i in range(8):
stats+=self.pinStats[i+8]<<i
stats=int(hex(stats),0)
self.i2c.writeto_mem(self.address,ConfigPort1,bytes([stats]))
def writePin(self,pin, value):
"""
Args:
Pin(int): 0-7 = IO0_0 - IO0_7 ; 8-15 = IO1_0-IO1_7
value(int): 0 = off ; 1 = on
"""
self.pinValues[pin]=value
vals=0
if self.pinStats[pin]:
print('ATTENTION Pin '+str(pin)+' at i2c address '+str(self.address)+' is configed as INPUT')
return
elif pin <= 7:
for i in range(8):
vals+=self.pinValues[i]<<i
vals=int(hex(vals),0)
self.i2c.writeto_mem(self.address,OutputPort0,bytes([vals]))
else:
for i in range(8):
vals+=self.pinValues[i+8]<<i
vals=int(hex(vals),0)
self.i2c.writeto_mem(self.address,OutputPort1,bytes([vals]))
# print(vals)
def readPin(self, pin):
"""Issue a measurement.
Args:
writeAddress (int): address to write to
:return:
"""
comeback = bytearray(1)
if not self.pinStats[pin]:
print('ATTENTION Pin '+str(pin)+' at i2c address '+str(self.address)+' is configed as OUTPUT')
return
elif pin <=7:
comeback =self.i2c.readfrom_mem(self.address,InputPort0,1)
else:
self.i2c.readfrom_mem(self.address,InputPort1,2)
raw = (comeback[0] >> (pin % 8)) & 1
return raw