مجهر OpenMV الكهربائي بدقة 0.0001 ملم
الدرس المرئي لشركة Singtown حول OpenMV - لقد صنعت مجهرًا كهربائيًا من OpenMV بدقة 0.0001 ملم!
مجهر OpenFlexure هو مجهر مفتوح المصدر، منخفض التكلفة، ومعياري (modular)، صُمم ليكون سهل التجميع والتشغيل والتعديل. يهدف هذا المشروع إلى توفير بديل مرن وسهل الوصول إليه للمجاهر المخبرية التقليدية، وذلك لأغراض التعليم والبحث والبيئات محدودة الموارد. أضافت شركة Singtown دعم OpenMV إلى هذا المجهر.
يتميز مجهر OpenFlexure بعدة مزايا:
(1) السمة الأبرز هي بنية المنصة (platform)، التي تستفيد من مرونة مادة PLA، فلا تتأثر بالاحتكاك أو الاهتزاز، والتجميع سهل جدًا.
(2) الدقة عالية جدًا، حيث تكون كل خطوة من المحرك الخطوي (stepper motor) أقل من مئة نانومتر، أي ما يعادل 1/100 من حجم خلية دم حمراء.
(3) ميزة أخرى هي أن تكلفة الطباعة ثلاثية الأبعاد منخفضة نسبيًا.
أروع ما في بنية المجهر هو استفادته من مرونة مادة PLA، حيث يُطبع الجسم الرئيسي كقطعة واحدة، وتتحرك المنصة دون احتكاك، مما يمنح دقة عالية جدًا تصل إلى 100 نانومتر. ونظرًا للقيود الكبيرة التي تفرضها المجاهر الجاهزة، لم نستخدم مجهرًا جاهزًا، بل عدّلنا تصميم مجهر OpenFlexure كمرجع، وصممنا المسار البصري بالكامل بأنفسنا.
أجرينا على مجهر OpenFlexure التعديلات التالية لتكييفه مع OpenMV، وأبرز هذه التعديلات أربعة:
(1) أولًا، عدّلنا أنبوب العدسة (lens tube) ليتناسب مع عنصر الاستشعار الضوئي لكاميرا OpenMV. ومن الجدير بالذكر أن هناك عدسة أنبوبية هنا، ولها وظيفتان: الأولى تقصير البعد البؤري للعدسة الشيئية (objective lens)، والثانية ضبط فتحة NA لجعل الصورة أكثر سطوعًا.
(2) ثانيًا، حسّنّا تصميم الدرج السفلي، وأضفنا أزرارًا ولوحة تشغيل للمحركات الخطوية، بحيث يمكن التحكم بدقة في حركة المجهر وضبط التركيز.
(3) ثالثًا، صممنا لجهاز OpenMV لوحة تشغيل جديدة للمحركات الخطوية، يمكنها التحكم في أربعة محركات خطوية عبر واجهة I2C.
(4) وأخيرًا، أضفنا شاشة LCD الخاصة بـ OpenMV، وصممنا نظام تغذية كهربائية مستقلًا للجهاز بأكمله.
البنية العامة
يتمثل التعديل الرئيسي في البنية العامة في استبدال حل Raspberry Pi الأصلي بحل OpenMV، مع إضافة أزرار وشاشة LCD، واستخدام تغذية مستقلة بالبطارية لتحقيق تشغيل مستقل.
صُنع المجهر بالكامل باستخدام تقنية الطباعة ثلاثية الأبعاد، مستفيدًا من المرونة العالية جدًا للقطع المطبوعة ثلاثية الأبعاد ذات نسبة الحشو المنخفضة والسماكة الرقيقة. لم يُصمَّم المجهر ببنية ميكانيكية معقدة، بل تم تحقيق حركة منصة العينة والعدسة بطريقة بارعة للغاية، وسيتم تفصيل ذلك لاحقًا.

حركة منصة العينة وأنبوب العدسة
في هذا الجزء، استفاد تصميم OpenFlexure من المرونة الكبيرة للقطع المطبوعة ثلاثية الأبعاد عند معاملات محددة لنسبة الحشو والسماكة، وصمم بنية بارعة جدًا لتحقيق حركة انتقالية (translation) لمنصة العينة.

البنية الأساسية التي تحقق حركة منصة العينة هي بنية شبيهة بالسلم كما هو موضح في الشكل أدناه. وعدم استقرار الشكل الرباعي الداخلي لهذه البنية هو جوهر تحقيق حركة منصة العينة. عند طباعة هذه البنية باستخدام طابعة ثلاثية الأبعاد، يجب التأكد من أن الطابعة قادرة على طباعة الدرجتين المتوسطتين من القضبان الأفقية، فهذه القضبان هي المفتاح لمنح البنية مرونة كافية، لذا فإن الأمر يتطلب مستوى أداء معينًا من الطابعة ثلاثية الأبعاد.

لنأخذ حركة منصة العينة على المحور X كمثال. بعد التأكد من طباعة الجسم الرئيسي للمجهر، لا يحتاج تحقيق حركة منصة العينة في اتجاه المحور X سوى مجموعة براغي وشريط مطاطي واحد. يمر البرغي عبر ترس كبير ويتصل بلوحة حامل السلم، وسيتم تدوير الترس الكبير لاحقًا بواسطة المحرك الخطوي. ويتصل الشريط المطاطي أيضًا بلوحة حامل السلم، ويوفر قوة معاكسة لقوة البرغي. الشكلان التاليان يوضحان تركيب البرغي وتركيب الشريط المطاطي:

يوفر البرغي والشريط المطاطي القوتين اللتين تحققان الحركة الانتقالية لمنصة العينة على المحور X. عندما يُدير المحرك الخطوي الترس الكبير باتجاه عقارب الساعة، يُشدّ البرغي، وتُرفع لوحة حامل السلم إلى الأعلى، وينحرف السلم إلى اليسار، مما يحقق حركة منصة العينة في الاتجاه الموجب لمحور X؛ وعندما يُدير المحرك الخطوي الترس الكبير عكس اتجاه عقارب الساعة، يرتخي البرغي، وتُسحب لوحة حامل السلم إلى الأسفل بفعل شد الشريط المطاطي، وينحرف السلم إلى اليمين، فتتحرك منصة العينة في الاتجاه السالب لمحور X:
شد البرغي

الحالة الأولية

ارتخاء البرغي

الحركة في اتجاه المحور Y تتبع نفس المبدأ.
تُستخدم نفس الطريقة أيضًا لتحريك أنبوب العدسة إلى الأعلى والأسفل: عندما يُشد البرغي، تُرفع اللوحة الحاملة إلى الأعلى فيتحرك أنبوب العدسة إلى الأسفل، وعندما يرتخي البرغي، يسحب الشريط المطاطي اللوحة الحاملة إلى الأسفل فيتحرك أنبوب العدسة إلى الأعلى.
تركيب أنبوب العدسة
فيما يتعلق بأنبوب العدسة، أُجريت بعض التعديلات لتكييفه مع عنصر الاستشعار الضوئي لكاميرا OpenMV. يستخدم التصميم الأصلي لمجهر OpenFlexure كاميرا arducam_b0196 الخاصة بـ Raspberry Pi، وأبعادها لا تتناسب مع عدسة OpenMV، لذا عُدِّل كود OpenSCAD الخاص بها لإضافة دعم لعدسة OpenMV:
العدسة الأصلية:

عدسة OpenMV:

تركيب العدسة المحدبة وعنصر الاستشعار الضوئي والعدسة الشيئية في أنبوب العدسة:
العدسة المحدبة:

عنصر الاستشعار الضوئي لـ OpenMV:

العدسة الشيئية:

التحكم في المحرك الخطوي
نظرًا للحاجة لاحقًا إلى إضافة أزرار وشاشة LCD، وأن المحرك الخطوي 28BYJ-48 يمكن التحكم فيه دون الحاجة إلى دارة تشغيل مخصصة للمحركات الخطوية، تم هنا استخدام شريحة PCA9555BS لتوسيع منافذ الإدخال/الإخراج (IO)، باستخدام اتصال I2C. وتُستخدم ترانزستورات دارلينغتون ULN2803LVS لتشغيل المحرك الخطوي. يرسل OpenMV عدد نبضات التشغيل عبر I2C إلى PCA9555BS، وتُخرج PCA9555BS إشارات عالية/منخفضة عبر منافذ IO المقابلة لتشغيل ترانزستورات دارلينغتون التي تُحرّك المحرك الخطوي:
يُهيَّأ OpenMV على أنه I2C برمجي (software 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، مع الاستمرار في استخدام I2C البرمجي لـ OpenMV، حيث P4 هو SCL وP5 هو SDA. لكن عنوان PCA9555BS الخاص بالأزرار هو 0x21، وتُهيَّأ منافذ IO في وضع الإدخال:
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 باستخدام أسلاك دوبونت، وتُثبَّت الشاشة على الجزء الخارجي من قاعدة المجهر.
القاعدة:

شاشة LCD:

لضمان عرض شاشة LCD للصورة كاملة، يُستخدم الكود التالي لتدوير الصورة الأصلية وتصغيرها بحيث تتناسب مع دقة الشاشة:
lcd.write(img.copy(x_scale=0.25, y_scale=0.267, hint=image.ROTATE_90))
عرض نتائج المجهر
ترتيب بكسلات RGB لشاشة هاتف محمول:

شريحة دودة خيطية (nematode):

مسحة دم ضفدع:

شريحة أوليات مهدبة (بارامسيوم):

كود 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
تنزيل ملفات الطباعة ثلاثية الأبعاد
ملفات الطباعة ثلاثية الأبعاد لمجهر OpenMV الكهربائي من Singtown