Моторизованный микроскоп OpenMV с точностью 0.0001 мм
Видеоурок SingTown OpenMV — я сделал моторизованный микроскоп OpenMV с точностью 0.0001 мм!
OpenFlexure Microscope — это микроскоп с открытым исходным кодом, недорогой и модульный, созданный так, чтобы его было легко собирать, использовать и модифицировать. Цель проекта — предложить гибкую и доступную альтернативу традиционным лабораторным микроскопам для образования, исследований и условий с ограниченными ресурсами. Компания SingTown добавила в этот микроскоп поддержку OpenMV.
У микроскопа OpenFlexure есть несколько преимуществ:
(1) Самая характерная особенность — конструкция платформы, использующая гибкость PLA-пластика: она не подвержена трению и вибрациям, а сборка очень проста.
(2) Очень высокая точность: один шаг шагового двигателя — менее ста нанометров, то есть 1/100 диаметра эритроцита.
(3) Ещё одно преимущество — сравнительно низкая стоимость 3D-печати.
Самое крутое в конструкции микроскопа — использование гибкости PLA-пластика: корпус печатается единым целым, платформа перемещается без трения, что даёт очень высокую точность — вплоть до 100 нанометров. Поскольку готовые микроскопы имеют слишком много ограничений, мы не стали использовать готовый микроскоп, а взяли за основу конструкцию OpenFlexure и доработали её, полностью спроектировав всю оптическую систему самостоятельно.
Чтобы адаптировать микроскоп OpenFlexure под OpenMV, мы внесли следующие изменения — всего четыре основных доработки:
(1) Во-первых, мы изменили тубус, чтобы он подходил под светочувствительный элемент камеры OpenMV. Стоит отметить, что здесь используется дополнительная линза в тубусе, которая выполняет две функции: сокращает фокусное расстояние объектива и регулирует числовую апертуру (NA), делая изображение более ярким.
(2) Во-вторых, мы улучшили конструкцию нижнего выдвижного отсека и добавили кнопки и плату драйвера шаговых двигателей, что позволяет точно управлять перемещением и фокусировкой микроскопа.
(3) В-третьих, мы разработали для OpenMV новую плату драйвера шаговых двигателей, которая управляет четырьмя шаговыми двигателями по шине I2C.
(4) И наконец, мы добавили LCD-дисплей OpenMV и разработали автономное питание всей системы.
Общая конструкция
Основное изменение общей конструкции заключается в замене исходного решения на базе Raspberry Pi на решение на базе OpenMV, добавлении кнопок и LCD-дисплея, а также использовании автономного питания от батареи для независимой работы.
Весь микроскоп изготовлен с помощью 3D-печати. За счёт использования тонкостенных деталей с низким процентом заполнения, обладающих чрезвычайно высокой гибкостью, в микроскопе не потребовалась сложная механическая конструкция — перемещение предметного столика и объектива реализовано чрезвычайно изящным способом, подробнее об этом рассказывается далее.

Перемещение предметного столика и тубуса
В этой части конструкция OpenFlexure использует высокую гибкость 3D-печатных деталей при определённых параметрах заполнения и толщины, что позволило создать очень изящную структуру для реализации перемещения предметного столика.

Ключевой элемент, обеспечивающий перемещение предметного столика, — это структура, похожая на лестницу, показанная на рисунке ниже. Неустойчивость внутреннего четырёхугольника этой структуры — основа механизма перемещения столика. При печати такой структуры на 3D-принтере необходимо убедиться, что принтер способен качественно напечатать две средние перекладины — именно они обеспечивают структуре необходимую гибкость, поэтому к характеристикам 3D-принтера предъявляются определённые требования.

Рассмотрим перемещение столика на примере оси X. После того как корпус микроскопа напечатан, для реализации перемещения столика вдоль оси X требуется всего один винт и одна резинка. Винт проходит через большую шестерню и соединяется с опорной пластиной «лестницы»; впоследствии большая шестерня будет приводиться в движение шаговым двигателем. Резинка также соединена с опорной пластиной «лестницы» и создаёт силу, противоположную силе винта. Схемы установки винта и резинки показаны на двух рисунках ниже:

Винт и резинка создают две силы, обеспечивающие перемещение столика вдоль оси X. Когда шаговый двигатель вращает большую шестерню по часовой стрелке, винт затягивается, опорная пластина «лестницы» приподнимается, «лестница» смещается влево, и столик перемещается в положительном направлении оси X; когда шаговый двигатель вращает шестерню против часовой стрелки, винт ослабевает, опорная пластина под действием резинки опускается вниз, «лестница» смещается вправо, и столик перемещается в отрицательном направлении оси X:
Винт затянут

Исходное состояние

Винт ослаблен

Перемещение по оси Y реализовано аналогично.
Перемещение тубуса вверх и вниз реализовано тем же способом: когда винт затянут, опорная пластина приподнимается и тубус опускается вниз; когда винт ослаблен, пластина под действием резинки опускается, и тубус поднимается вверх.
Устройство тубуса
В тубус были внесены частичные изменения для адаптации к светочувствительному элементу камеры OpenMV. В исходном решении OpenFlexure использовалась камера Raspberry Pi arducam_b0196, размеры которой не подходят под объектив OpenMV, поэтому исходный код OpenSCAD был изменён, чтобы добавить поддержку объектива OpenMV:
Исходный объектив:

Объектив OpenMV:

В тубусе устанавливаются собирающая линза, светочувствительный элемент и объектив:
Собирающая линза:

Светочувствительный элемент OpenMV:

Объектив:

Управление шаговыми двигателями
Учитывая, что впоследствии потребуется добавить кнопки и LCD-дисплей, а шаговый двигатель 28BYJ-48 можно приводить в движение без специального драйвера, здесь для расширения портов ввода-вывода используется микросхема PCA9555BS с интерфейсом I2C. Для управления шаговым двигателем используется транзисторная сборка Дарлингтона ULN2803LVS. OpenMV по шине I2C отправляет такты управления на PCA9555BS, а PCA9555BS выдаёт на соответствующие выводы высокий или низкий уровень, управляя сборкой Дарлингтона, которая приводит в движение шаговый двигатель:
OpenMV настраивается на программный I2C: P4 — SCL, P5 — SDA:
i2c = SoftI2C(scl=Pin("P4"), sda=Pin("P5"))
Адрес PCA9555BS — 0x20, все выводы инициализируются в режиме выхода:
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, также по программному I2C OpenMV: P4 — SCL, P5 — SDA. Однако адрес PCA9555BS для кнопок — 0x21, а выводы настроены в режиме входа:
pca2 = PCA9555(i2c, address = 0x21)
for i in range(16):
pca2.inputPins(i)
Плата расширения для кнопок:

Принципиальная схема платы расширения для кнопок:

PCB-лоток
В исходном решении лоток микроскопа был рассчитан на Raspberry Pi и не имел выключателя, дисплея и кнопок, из-за чего микроскоп не мог работать автономно. Лоток был переработан под OpenMV, в нём добавлены отверстия под кнопки, USB-разъём для отладки OpenMV и паз под выключатель.
Исходный лоток:

Лоток для OpenMV:

Плата PCB устанавливается на лоток, к ней подключаются провода Dupont для LCD-дисплея, устанавливается батарея, после чего всё помещается в основание:
Вид спереди:

Вид сверху:

Вид справа:

LCD-дисплей
Используется LCD-дисплей, поставляемый в комплекте с OpenMV. Дисплей подключается к OpenMV проводами Dupont и закрепляется снаружи на основании микроскопа.
Основание:

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
Скачать файлы для 3D-печати
Файлы для 3D-печати моторизованного микроскопа OpenMV от SingTown