Microscope motorisé avec caméra HQ pour Raspberry Pi et interface HTML • Domotique et objets connectés à faire soi-même

La caméra HQ pour Raspberry Pi offre une résolution de 12,3MP ainsi qu’un adaptateur pour monture C qui permet d’utiliser des objectifs professionnels dédiés à l’analyse d’image. Pour ce projet de microscope, nous allons utiliser des éléments d’une CNC 3018 pour construire un statif motorisé. La caméra est installée sur un axe vertical imprimé en 3D. L’axe vertical est motorisé par un moteur Nema 17 ptiloté depuis un contrôleur A4988 en Python à l’aide de la librairie RpiMotorLib de Gavin Lyons. En ajoutant des tubes d’extension, il est possible d’obtenir un grossissement plus important pour aller jusqu’au microscope d’appoint.

Avant de débuter, vous pouvez commencer par lire cet article qui explique comment piloter un moteur Nema 17 à l’aide de la librairie Python RpiMotorLib.

Pour en savoir plus sur la caméra HQ, je vous conseille de lire cette excellente et très complète série d’articles réalisée par notre ami François Mocq.

Composants utilisés pour le support (statif)

Voici les composants recyclés d’une CNC 3018 utilisés pour le statif :

  • x2 profilés aluminium 2020 (section carrée 20x20mm) de longueur 220mm environ
  • x2 profilés aluminium 2020 (section carrée 20x20mm) de longueur 350mm environ
  • x4 équerres 30mm
  • Ecrous en T pour profilé aluminium 2020

Vous pouvez également imprimer en 3D vos profilés 2020 à l’aide de ce projet disponible sur Thingiverse.

Et les éléments pour l’axe vertical motorisé

  • x2 tiges ø10mm de longueur 400mm mini
  • x2 guidages linéaires ø10mm
  • x4 supports pour tige ø10mm
  • x1 platine moteur Nema
  • x1 vis T8 de longueur 350mm minimum
  • x1 élément de transmission direct à vis
  • x1 raccord flexible ø8mm

Vous trouverez également tous les composants mécaniques nécessaires dans cet article

Caméra HQ, objectif, tube d’extension

  • Caméra HQ 12.3MP v1.0 2018 avec support pour objectif à monture C
  • Objectif 50mm à monture C
  • Anneau lumineux Neopixel 16 LED RGB
  • Kit de tubes d’extension / bague allonge

Vous trouverez tous les composants nécessaires dans cet article

Comment choisir la longueur du tube d’extension ?

Le statif présenté dans ce projet est équipé d’un objectif 50mm avec un tube allonge de 40mm ce qui permet un mode macro satisfaisant pour l’électronique.

La bague allonge (ou tube d’extension) vient se visser entre la caméra HQ et l’objectif. Cela permet de modifier la distance focale et ainsi d’obtenir un grossissement supérieur. C’est une solution économique pour obtenir un mode macro sans investir dans un objectif dédié.

Objectif Pentax/Cosmicar C5028-M installé sur la Caméra HQ du Raspberry Pi via un via tube d’extension de 50mm (40+10).

Vous pouvez également imprimer vos bagues d’extension en utilisant ces fichiers STL. Le pas de vis étant très fin, imprimez avec une résolution fine de 0,1mm idéalement.

Attention toutefois, car plus la bague allonge est grande, plus la profondeur de champ est courte. Il ne sera pas possible de faire la netteté sur l’ensemble de l’image lorsqu’il y a du relief. C’est à dire qu’il sera impossible de voir nettement le circuit et les composants électroniques.

Pour estimer la distance de mise au point vous pouvez lire cet article publié par Nikon Passion.

Voici quelques grandissements et distances de mise au point pour un objectif de 50mm en fonction de la bague allonge employée. La distance est calculée lorsque la mise au point est faite à l’infini.

Bague allonge (mm) 10 20 30 40 50 60 70 80 90 100 110 120
Grandissement 0,20 0,40 0,60 0,80 1,00 1,20 1,40 1,60 1,80 2,00 2,20 2,40
Distance de l’objet (mm)* 300 175 133 113 100 92 86 81 78 75 73 71

(*) distance théorique approximative

Comment calculer (estimer) le grossissement optique

Il est assez facile d’estimer le grossissement optique à partir d’une mesure connue par rapport à ce qu’on obtient sur l’écran.

Ici, par exemple, on peut lire environ 9 graduations d’un pied à coulisse sur l’écran se qui représente 9mm. Sur mon écran, je mesure environ 165mm, ce qui nous donne un grossissement de 165 / 9 ∼ x18.

Circuit électronique

Les composants récupérés sur une CNC 3018

Voici le repérage des broches du circuit

Pilote moteur pas à pas A4988
Alimentation 12V Broche A4988
+12V de la batterie ou de l’alimentation secteur VMOT
GND de la batterie ou de l’alimentation secteur GND
GPIO du Raspberry Pi (toute version)
5V du Raspberry Pi VDD
GND du Raspberry Pi GND
GPIO 21 STP
GPIO 20 DIR
Vers le moteur pas à pas

Généralement le moteur est livré avec un câble muni d’un connecteur au pas de 2,54mm. Si le déplacement est inversé, inverser le connecteur ou modifiez le code Python

1A, 1B, 2A, 2B
GPIO 14 MS1
GPIO 15 MS2
GPIO 18 MS3
Connecter les broches RESET et SLEEP ensembles
Anneau lumineux Neopixel 16 LED RGB  
GND du Raspberry Pi connectée au GND de l’alimentation externe GND
+5V alimentation externe VIN
GPIO 12 In

Remarques

La librairie Adafruit Neopixel ne prend en charge que les broches 10, 12, 18 et 21 du Raspberry Pi. Pour pouvoir utiliser la broche 10, il faudra activer le bus SPI depuis les réglages système de Raspberry Pi OS.

Toutes les masses (GND) doivent être connectées ensembles.

Pièces à imprimer en 3D

Voici les pièces à imprimer en 3D. Les fichiers STL ainsi que les modèles Fusion 360 sont disponibles sur Thingiverse, Cults3D et GitHub.

Support pour caméra HQ v1.0

Le support est conçu pour les éléments d’une CNC 3018 (guidage à bille, transmission par vis)

Support pour Raspberry Pi à fixer sur la base du statif
Support anneau lumineux (recommandé) compatible avec n’importe quel objectif à monture C
Bague allonge monture C.

Le pas de vis étant très fin, imprimez avec la résolution la plus fine (0,1mm en général) qu’offre votre imprimante.

Fichier STL à télécharger sur Thingiverse

Assemblage du statif

Voici quelques photos qui montrent l’assemblage des profilés aluminium 2020.

Vue du dessus

Vue arrière gauche. Fixation de l’axe vertical et du moteur Nema 17 sur son support.

Vue arrière droite. Fixation de la vis T8 à l’aide d’un accouplement souple sur l’arbre de sortie du moteur pas à pas Nema 17

Le support pour le Raspberry Pi (tout modèle) vient se fixer à l’aide l’un écrou en T à droite ou à gauche de l’axe vertical.

Le Raspberry Pi est vissé sur le support vertical imprimé en 3D sur le profilé en aluminium 2020 à l’aide d’un écrou en T

Branchement sur le GPIO

Branchement du moteur Nema 17 sur le contrôleur A4988. Bloc d’alimentation 3V / 5V / 12V pour moteur Nema 17 et anneau de LED Neopixel.

La caméra HQ est installée sur le support imprimé en 3D. La longueur du ruban livré en standard est suffisant si le Raspberry Pi est installé à la vertical sur le support proposé.

Objectif 50mm installé sur le support imprimé en 3D

Anneau de LED Neopixel installé dans le support imprimé en 3D puis inséré sur l’objectif.

Installer les librairies Python

Le projets nécessite l’installation des librairies Python suivantes

  • RpiMotorLib
  • PiCamera
  • Adafruit NeoPixel

Pour vérifier que la version 3 de Python est bien installée (ce qui est toujours le cas sur Raspberry Pi OS normalement), exécutez la commande suivante dans un Terminal.

Si Python3 est correctement installé, vous devez obtenir le numéro de version en réponse.

python3 --version
Python 3.7.3

Pour installer les librairies Python, on aura besoin du script pip3. Pour vérifier qu’il est bien installer, exécuter cette commande qui doit vous renvoyer le chemin vers le script

pip3 --version

Si pip3 n’est pas installé, exécuter

sudo apt install python3-pip

Maintenant, on peut installer les librairies

sudo pip3 install rpi_ws281x adafruit-circuitpython-neopixel
sudo python3 -m pip install --force-reinstall adafruit-blinka
sudo pip3 install rpimotorlib
sudo apt install python3-picamera

Activer la caméra, les bus SPI et I2C

L’accès au GPIO et à certaines broches de ce dernier nécessitent un activation. Il faudra également activer l’accès au port CSI avant de pouvoir utiliser la caméra.

Depuis le menu Préférences, ouvrir la Configuration

Activer I2C, SPI, caméra

Code Python du projet. Flux vidéo de la caméra HQ diffusé sur une interface HTML de commande

Le code source qui permet de récupérer le flux vidéo de la caméra est directement tiré de l’exemple Web Streaming disponible dans la documentation en ligne de la librairie PiCamera.

Il aurait été beaucoup plus facile de développer l’interface avec Flask (ce que j’ai essayé), mais le flux vidéo obtenu est beaucoup moins fluide 😥 En attendant de trouver une solution technique plus efficace, nous allons utiliser les fonctions HTTP standards de Python.

L’interface utilise la feuille de style de Bootstrap 4 ce qui permet d’obtenir un rendu professionnel et responsive quelque soit la taille de l’écran y compris sur un smartphone.

L’interface HTML est accessible sur le réseau local. Le Raspberry Pi offre suffisamment de puissance pour que plusieurs utilisateurs puissent visualiser le flux vidéo simultanément. Pratique pour une salle de classe.

Plusieurs paramètres peuvent être personnalisées avant de lancer le script

  • GPIO_pins broches MS1, MS2 et MS3 pour communiquer avec le contrôleur A4988
  • direction broche pour piloter la direction du moteur Nema 17
  • step broche pour piloter l’avance du moteur Nema 17
  • step_per_mm calibration du moteur Nema. Par défaut 72 pas par millimètre
  • distance 72 pas à parcourir par défaut, soit 1mm.
  • LED_COUNT nombre de LED de l’anneau lumineux Neopixel
  • LED_PIN broche pour piloter le contrôleur WS2812B de l’anneau lumineux Neopixel. Attention, la broche 10 ne fonctionne pas (sur mon Raspberry Pi 3). C’est la seule broche PWM du Raspberry Pi disponible, les autres (D18 et D21) sont utilisées pour piloter le contrôleur A4988
  • LED_BRIGHTNESS niveau de luminosité.  Contrairement à la version Arduino, il n’existe aucune fonction pour modifier le niveau de luminosité après initialisation de la librairie Neopixel Adafruit pour le langage Python.
  • LED_ORDER ordre des LED. Par défaut RGB. Modifier si la couleur obtenue n’est pas conforme à ce qui est demandé depuis l’interface HTML.

Code source

# Source code based on the Web streaming example from the official PiCamera package
# http://picamera.readthedocs.io/en/latest/recipes2.html#web-streaming

import io, picamera, logging, socketserver, os
from statistics import mean 
from threading import Condition, Thread
from http import server
import RPi.GPIO as GPIO
from RpiMotorLib import RpiMotorLib
import board, time, neopixel

#define GPIO pins
GPIO_pins = (14, 15, 18)    # Microstep Resolution MS1-MS3 -> GPIO Pin
direction= 20               # Direction Pin 
step = 21                   # Step Pin
step_per_mm = 72            # Step by millimeter | stepper per millimeter
distance = 72               # By default move 1mm => 72 steps per mm
stepper = "1mm"
debug    = False

# LED strip configuration
LED_COUNT   = 16            # Number of LED ring.     | Nombre de LED
LED_PIN     = board.D12     # GPIO pin. Don't use D10 | Ne fonctionne pas sur la broche D10
LED_BRIGHTNESS = 1          # LED brightness, from 0 to 1 | Niveau de luminosité, compris entre 0 à 1
LED_ORDER = neopixel.GRB    # Order of LED colours. May also be RGB, GRBW, or RGBW | Type de LED

ring_status = False         # LED Ring status
ring_status_label = "Off"   # LED Ring status for user | Etat de l'anneau de LED affiché sur la page Web
color = "white"             # Default color | Couleur d'éclairage par défaut (blanc)

camera_rotation = 270       # Image rotation in degrees (0,90,180,270) | rotation de l'image en degrées (0,90,180,270)

# Create instance for the Stepper Motor
mymotortest = RpiMotorLib.A4988Nema(direction, step, GPIO_pins, "A4988")
print("A4988 initialized")

# Neopixel Ring object
# auto_write must be set to False to change color | l'option auto_write doit être False pour pouvoir changer de couleur 
ring = neopixel.NeoPixel(board.D12, LED_COUNT, brightness = LED_BRIGHTNESS, auto_write=False, pixel_order = LED_ORDER)
ring.fill((0,0,0))
ring.show()

class StreamingOutput(object):
    def __init__(self):
        self.frame = None
        self.buffer = io.BytesIO()
        self.condition = Condition()

    def write(self, buf):
        if buf.startswith(b'\xff\xd8'):
            # New frame, copy the existing buffer's content and notify all
            # clients it's available
            self.buffer.truncate()
            with self.condition:
                self.frame = self.buffer.getvalue()
                self.condition.notify_all()
            self.buffer.seek(0)
        return self.buffer.write(buf)

class StreamingHandler(server.BaseHTTPRequestHandler):
    def getPage(self):
        PAGE="""
            
                
                    
                    

                    
                    

                    Raspberry Pi HQ Camera Magnifying
                
                
                
                    
                    
                        
                    
                    
                        
                            
                                
                                    Up
                                
                        
                             
                            
                                
                                    Down
                                
                               
                        
                        
                        
                            
                                
                                    {button_label}
                                
                            
                                
                                
                                     W 
                                
                            
                                 
                                
                                     R 
                                
                            
                                 
                                
                                     Y 
                                
                            
                                 
                                
                                     G 
                                
                                
                        
                        
                            
                            0.1 mm
                            1 mm
                            10 mm
                            
                        
                        

Info

                        
Step: {step}
LED: {ring_status}
Color: {ring_color} """.format(ring_status=ring_status_label, ring_color=color,button_label= "OFF" if ring_status else "ON", step=stepper) return PAGE def changeLedColor(self): global ring_status, color if ring_status == True: print("color changed to ", color) if color == "red": print('red') ring.fill((255,0,0)) ring.show() elif color == "yellow": print('yellow') ring.fill((255,255,0)) ring.show() elif color == "green": print('green') ring.fill((70,245,10)) ring.show() else: print('white') ring.fill((255,255,255)) ring.show() else: print("Ring if OFF") def do_POST(self): global distance, ring_status, color, stepper, ring_status_label, step_per_mm, distance content_length = int(self.headers['Content-Length']) post_data = self.rfile.read(content_length) if self.path == '/up': print("Go Up to ", distance) mymotortest.motor_go(False, "Full" , distance, 0.001 , False, .05) self.send_response(301) self.send_header('Location', '/index.html') self.end_headers() elif self.path == '/down': print("Go Down to ", distance) mymotortest.motor_go(True, "Full" , distance, 0.001 , False, .05) self.send_response(301) self.send_header('Location', '/index.html') self.end_headers() elif self.path == "/changecolor": color = post_data[10:].decode('utf-8') self.changeLedColor() self.send_response(301) self.send_header('Location', '/index.html') self.end_headers() elif self.path == "/switchonoffring": try: if ring_status == False: ring_status = True ring_status_label = "On" self.changeLedColor() print("Switch ON LED") else: ring.fill((0, 0, 0)) ring.show() ring_status_label = 'Off' ring_status = False print("Switch OFF LED") except Exception as e: logging.warning('Neopixel error: %s', str(e)) self.send_response(301) self.send_header('Location', '/index.html') self.end_headers() elif self.path == '/setdistance': distance = int(post_data[9:]) stepper = str( round(distance / step_per_mm, 1) ) + "mm" print("Set stepper to ", distance) self.send_response(301) self.send_header('Location', '/index.html') self.end_headers() else: self.send_error(404) self.end_headers() def do_GET(self): if self.path == '/': self.send_response(301) self.send_header('Location', '/index.html') self.end_headers() elif self.path == '/index.html': content = self.getPage().encode('utf-8') self.send_response(200) self.send_header('Content-Type', 'text/html') self.send_header('Content-Length', len(content)) self.end_headers() self.wfile.write(content) elif self.path == '/stream.mjpg': self.send_response(200) self.send_header('Age', 0) self.send_header('Cache-Control', 'no-cache, private') self.send_header('Pragma', 'no-cache') self.send_header('Content-Type', 'multipart/x-mixed-replace; boundary=FRAME') self.end_headers() try: while True: with output.condition: output.condition.wait() frame = output.frame self.wfile.write(b'--FRAME\r\n') self.send_header('Content-Type', 'image/jpeg') self.send_header('Content-Length', len(frame)) self.end_headers() self.wfile.write(frame) self.wfile.write(b'\r\n') except Exception as e: logging.warning( 'Removed streaming client %s: %s', self.client_address, str(e)) else: self.send_error(404) self.end_headers() class StreamingServer(socketserver.ThreadingMixIn, server.HTTPServer): allow_reuse_address = True daemon_threads = True with picamera.PiCamera(resolution='1296x972', framerate=24) as camera: output = StreamingOutput() #Uncomment the next line to change your Pi's Camera rotation (in degrees) camera.rotation = camera_rotation camera.start_recording(output, format='mjpeg') try: #print("try to get distance") address = ('', 8000) server = StreamingServer(address, StreamingHandler) server.serve_forever() #finally: # print("Stop camera stream") # camera.stop_recording() except KeyboardInterrupt: print("Shtdown HTTP server") server.shutdown() print("Close camera") camera.stop_recording() print("Switch off LED and cleanup GPIO") ring.fill((0,0,0)) ring.show() time.sleep(0.1) ring.deinit() GPIO.cleanup() os._exit(0)

Enregistrer le script Python

Explication du code

Nous allons détailler ici uniquement les points les plus importants du code source.

La librairie Python Adafruit NeoPixels permet de contrôler indépendamment chaque LED pilotées par le circuit WS2812B. Contrairement à la version Arduino, il n’est pas possible de modifier le niveau de luminosité (brightness) sauf à libérer la ressource et re-créer un nouvel objet NeoPixel. Pour pouvoir changer la couleur, il faut initialiser le paramètre auto_write à False.

La broche D10 (bus SPI) pose problème sur mon Raspberry Pi (à moins que cela ne provienne de la librairie). Il n’y a que la broche D12 disponible pour ce projet.

ring = neopixel.NeoPixel(board.D12, LED_COUNT, brightness = LED_BRIGHTNESS, auto_write=False, pixel_order = LED_ORDER)

La méthode fill() permet de changer la couleur de toutes les LED de l’anneau. On applique le changement en exécutant la méthode show().

ring.fill((255,0,0))
ring.show()

L’interface HTML est gérée par un serveur HTTP Python classique. L’interface HTML est utilise le thème Bootstrap déjà utilisé avec Flask.

Contrairement à Flask, le data binding (mise à jour automatique d’état) n’est pas disponible. Il faut donc renvoyer à chaque mise à jour une version actualisée de l’interface. Comme celle-ci est stockée dans une chaîne de caractère, il suffit d’utiliser la méthode format pour substituer la valeur de chaque variable.

PAGE="""
   ...
     
Step: {step}
LED: {ring_status}
Color: {ring_color} ... """.format(ring_status=ring_status_label, ring_color=color,button_label= "OFF" if ring_status else "ON", step=stepper)

La méthode StreamingHandler() permet d’intercepter toutes les requêtes POST et GET envoyées au serveur HTTP depuis le la navigateur. Par exemple ici, on intercepte la commande UP pour déplacer l’axe vertical du microscope à l’aide du moteur Nema 17. La colonne de droite montre la différence d’écriture par rapport à Flask.

Serveur Python HTTP standard Avec Flask
if self.path == '/up': 
    mymotortest.motor_go(False, "Full" , distance, 0.001 , False, .05) 
    self.send_response(301) 
    self.send_header('Location', '/index.html') 
    self.end_headers()
@app.route("/up", methods=["POST"]) 
def up(): 
    global distance print("Move up,", distance, "steps") 
    mymotortest.motor_go(False, "Full" , distance, 0.01 , False, .05) 
    return redirect(request.referrer)

Pour arrêter le script, on intercepte la combinaison de touche Ctrl + C de manière à

  • server.shutdown() arrêter le serveur HTTP
  • camera.stop_recording() arrêter le flux vidéo et libérer la caméra
  • ring.fill((0,0,0)) éteindre l’anneau lumineux
  • ring.deinit() libérer les ressources de l’objet NeoPixel
  • GPIO.cleanup() libérer le GPIO
  • os._exit(0) quitter le script Python

Pour pouvoir piloter l’anneau de LED Neopixel depuis le GPIO du Raspberry Pi, il faut démarrer le script en mode administrateur en faisant précédé la commande par un sudo.

Ouvrez le Terminal et placez vous dans le répertoire du script Python, par exemple de la dossier Document

cd /home/pi/Documents

Puis lancer le script en faisant précédé la commande d’un sudo

sudo python3 nom_du_script.py

L’interface HTML du microscope

L’interface HTML affiche le flux vidéo provenant de la caméra HQ. Sur le coté de l’image, on trouve les commandes UP et DOWN pour faire monter et descendre l’axe Z du statif.

Un sélecteur permet de choisir le déplacement à effectuer. 0.1mm 1mm ou 10mm à chaque mouvement. Vous pouvez modifier le code source pour changer cette pré-sélection.

La commande ON/OFF permet d’allumer l’anneau lumineux. Il est possible de changer la couleur de l’éclairage. Par défaut en blanc, rouge, jaune et vert.

Les réglages sont affichés sous le sélecteur

Exemples de grossissement obtenu avec différent éclairage

En faisant varier la couleur de l’éclairage à LED, il est possible de mettre en évidence certains détails du circuits (composants, pistes, inscriptions…).

Blanc

Rouge

Vert

Jaune

Voici une démonstration en vidéo

https://projetsdiy.fr/data/uploads/2020/11/demo-hq-camera-microscope-stand-raspberry-pi.mp4?_=1

Voici quelques clichés à différents grossissements d’un ESP8266EX installé sur une carte de développement ESP01.

Eclairage blanc avec 100% de luminosité.

Tube allonge 30m Tube allonge 40mm Tube allonge 50mm
Grossissement x18 Grossissement x26 Grossissement x33

Observation d’une LED RGB

Juste pour le fun, voici deux clichés d’une LED RGB de l’anneau Neopixel utilisé pour l’éclairage. On peut visualiser très clairement le circuit intégré, les connexions et chaque source LED pour la couleur Rouge, Verte et Bleu.

Anneau allumé Anneau éteint

Essai d’observation d’épiderme d’oignon

Voici un dernier essai réalisé avec un un tube d’extension de 85mm. J’ai remplacé le bleu de méthylène par du colorant alimentaire pour mettre en évidence les cellules de l’épiderme. Pour obtenir le cliché, l’éclairage a été posé sur la table.

On distingue très bien les cellules mais pas du tout le noyau (certainement à cause du colorant utilisé et du grossissement trop faible).

Ce montage reste encore limité pour des observations scientifiques.

Observation de l’épiderme d’un oignon.

Afficher l’interface en plein écran, mode kiosk

Toutes les navigateurs internent proposent une version adaptée au écrans d’affichage. Par rapport à la touche F11, le mode Kiosk offre les avantages suivants :

  • L’utilisateur ne peut pas voir les détails du bureau ou du système d’exploitation
  • Le bouton X (fermer) est masqué
  • La touche F11 est désactivée
  • Les barres de menus, les barres d’outils ne sont pas visibles
  • La barre d’état en bas n’est pas visible
  • Le menu contextuel du clic droit ne fonctionne pas
  • Les liens de destination ne sont pas visibles lors du survol des liens

Ajouter un onglet au Terminal ou ouvrez un nouveau Terminal puis exécuter la commande suivante

chromium-browser -kiosk 127.0.0.1:8000

Chromium s’ouvre en plein écran sans la barre d’adresse et les ascenseurs. Super pratique pour faire ses soudures sous un microscope !

Pour en savoir plus sur le mode kiosk de Chromium, lisez ce tutoriel

English Version

Avez-vous aimé cet article ?

[Total: 1 Moyenne: 5]