DIY Projects

Motorized microscope with HQ camera for Raspberry Pi and HTML interface (Python)

raspberry pi hq camera microscope cnc 3018 parts python interface neopixel ring led

The HQ Camera for Raspberry Pi offers a resolution of 12.3MP as well as a C-mount adapter that allows the use of professional lenses dedicated to image analysis. For this microscope project, we will use parts of a CNC 3018 to build a motorized stand. The camera is installed on a vertical axis printed in 3D. The vertical axis is motorized by a Nema 17 motor driven from an A4988 controller in Python using the RpiMotorLib library from Gavin Lyons. By adding extension tubes, it is possible to achieve greater magnification to go up to the supplemental microscope.

 

Before getting started, you can start by reading this article which explains how to drive a Nema 17 engine using the Python RpiMotorLib library.

Components used for the support (stand)

Here are the recycled components from a CNC 3018 used for the stand:

You can also 3D print your 2020 profiles using this project available on Thingiverse.

And the elements for the motorized vertical axis

You will also find all the necessary mechanical components in this article

HQ camera, lens, extension tube

You will find all the necessary components in this article

How to choose the length of the extension tube?

The stand presented in this project is equipped with a 50mm lens with a 40mm extension tube which allows a satisfactory macro mode for electronics.

The extension ring (or extension tube) is screwed between the HQ camera and the lens. This allows the focal length to be changed and thus to obtain a higher magnification. It is an economical solution to obtain a macro mode without investing in a dedicated lens.

Pentax / Cosmicar C5028-M lens installed on the HQ Camera of the Raspberry Pi via a via 50mm extension tube (40 + 10).

You can also print your extension rings using these STL files. The screw pitch being very fine, print with a fine resolution of 0.1mm ideally.

Be careful, however, because the larger the extension ring, the shorter the depth of field. It will not be possible to sharpen the entire image when there is relief. That is, it will be impossible to clearly see the circuit and the electronic components.

To estimate the focusing distance you can read this article published by Nikon Passion.

Here are some magnifications and focusing distances for a 50mm lens depending on the extension ring used. The distance is calculated when the focus is at infinity.

Extension ring (mm) 10 20 30 40 50 60 70 80 90 100 110 120
Magnification 0.20 0.40 0.60 0.80 1.00 1.20 1.40 1.60 1.80 2.00 2.20 2.40
Object distance (mm) * 300 175 133 113 100 92 86 81 78 75 73 71

(*) approximate theoretical distance

How to calculate (estimate) optical magnification

It is quite easy to estimate the optical magnification from a known measurement compared to what you get on the screen.

Here, for example, we can read about 9 graduations of a caliper on the screen which represents 9mm. On my screen, I measure around 165mm, which gives us a magnification of 165/9 ∼ x18.

Electric circuit

Components recovered on a CNC 3018

Here is the pinpointing of the circuit

Stepper Motor Driver A4988
12V power supply Pin A4988
+ 12V from battery or mains power VMOT
GND from battery or mains power GND
Raspberry Pi GPIO (any version)
5V from Raspberry Pi VDD
Raspberry Pi GND GND
GPIO 21 STP
GPIO 20 DIR
To stepper motor

Usually the motor is delivered with a cable fitted with a 2.54mm pitch connector. If the displacement is reversed, reverse the connector or modify the Python code

1A, 1B, 2A, 2B
GPIO 14 MS1
GPIO 15 MS2
GPIO 18 MS3
Connect RESET and SLEEP pins together
Neopixel 16 RGB LED ring light  
GND of the Raspberry Pi connected to the GND of the external power supply GND
+ 5V external power supply WIN
GPIO 12 In

 

Notes

The Adafruit Neopixel library only supports pins 10, 12, 18 and 21 of the Raspberry Pi. To be able to use pin 10, the SPI bus must be activated from the Raspberry Pi OS system settings.

All the grounds (GND) must be connected together.

Parts to 3D print

Here are the parts to be 3D printed. STL files as well as Fusion 360 models are available on Thingiverse, Cults3D and GitHub.

HQ v1.0 camera support

The support is designed for the elements of a CNC 3018 (ball guide, screw transmission)

Support for Raspberry Pi to be fixed on the base of the stand
Light ring mount (recommended) compatible with any C-mount lens
Extension ring for C-mount.

The thread is very fine, print with the finest resolution (0.1mm in general) offered by your printer.

STL file to download on Thingiverse

Assembly of the stand

Here are some photos showing the assembly of the 2020 aluminum profiles.

Top view

Left rear view. Fixing the vertical axis and the Nema 17 motor on its support.

Right rear view. Fixing the T8 screw using a flexible coupling on the output shaft of the Nema 17 stepper motor

The support for the Raspberry Pi (any model) is fixed using a T-nut to the right or to the left of the vertical axis.

The Raspberry Pi is screwed to the 3D printed vertical support on the 2020 aluminum profile using a T-nut

Connection to the GPIO

Connection of the Nema 17 motor to the A4988 controller. 3V / 5V / 12V power supply for Nema 17 motor and Neopixel LED ring.

The HQ camera is installed on the 3D printed stand. The length of the ribbon supplied as standard is sufficient if the Raspberry Pi is installed vertically on the proposed support.

50mm lens installed on the 3D printed support

Neopixel LED ring installed in the 3D printed holder then inserted on the lens.

Install the Python libraries

The project requires the installation of the following Python libraries

To verify that Python version 3 is installed correctly (which is always the case on Raspberry Pi OS normally), run the following command in a Terminal.

If Python3 is properly installed, you should get the version number in response.

python3 --version
Python 3.7.3

To install the Python libraries, we will need the pip3 script. To verify that it is correctly installed, run this command which should send you the path to the script

pip3 --version

If pip3 is not installed, run

sudo apt install python3-pip

Now we can install the libraries

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

Activate the camera, the SPI and I2C buses

Access to the GPIO and some GPIO pins require activation. You will also need to enable access to the CSI port before you can use the camera.

From the Preferences menu, open Configuration

Enable I2C, SPI and camera

Python code of the project. HQ camera video stream broadcast on an HTML control interface

The source code that allows you to retrieve the video stream from the camera is taken directly from the Web Streaming example available in the online documentation of the PiCamera library.

Attention, the script only works in Python 3.

It would have been much easier to develop the interface with Flask (which I tried), but the video stream obtained is much less fluid: cry: Until we find a more efficient technical solution, we will use the HTTP functions Python standards.

The interface uses the Bootstrap 4 style sheet which allows to obtain a professional and responsive rendering whatever the size of the screen, including on a smartphone.

The HTML interface is accessible on the local network. The Raspberry Pi offers enough power for multiple users to view the video stream simultaneously. Convenient for a classroom.

Several parameters can be customized before running the script

Source Code

# 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="""
            <html>
                <head>
                    <meta charset="utf-8">
                    <!-- <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=yes"> -->

                    <!-- Bootstrap CSS -->
                    <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/css/bootstrap.min.css" integrity="sha384-Gn5384xqQ1aoWXA+058RXPxPg6fy4IWvTNh0E263XmFcJlSAwiGgFAW/dAiS6JXm" crossorigin="anonymous">

                    <title>Raspberry Pi HQ Camera Magnifying</title>
                </head>
                <body>
                <div class="container">
                    <div class="row">
                    <div class="col-sm-9" style="padding:10">
                        <img src="stream.mjpg" style="width:100%">
                    </div>
                    <div class="col-sm-3" style="padding:10">
                        <div class="row">
                            <div class="col-4">
                                <form method="POST" action="up">
                                    <button type="submit" class="btn btn-primary">Up</button>
                                </form>
                        
                            </div> 
                            <div class="col-4">
                                <form method="POST" action="down" class="center-block">
                                    <button type="submit" class="btn btn-primary">Down</button>
                                </form>
                            </div>   
                        </div>
                        <div></div>
                        <div class="row">
                            <div class="col-3">
                                <form method="POST" action="switchonoffring">
                                    <button name="ringcolor" value="light" type="submit" style="font-size=40" class="btn btn-primary" >{button_label}</button>
                                </form>
                            </div>
                            <div class="col-2">    
                                <form method="POST" action="changecolor">
                                    <button name="ringcolor" value="light" type="submit" style="font-size=40" class="btn btn-light" > W </button>
                                </form>
                            </div>
                            <div class="col-2">     
                                <form method="POST" action="changecolor" >
                                    <button name="ringcolor" value="red" type="submit" class="btn btn-danger"> R </button>
                                </form>
                            </div>
                            <div class="col-2">     
                                <form method="POST" action="changecolor" >
                                    <button name="ringcolor" value="yellow" type="submit" class="btn btn-warning"> Y </button>
                                </form>
                            </div>
                            <div class="col-2">     
                                <form method="POST" action="changecolor" >
                                    <button name="ringcolor" value="green" type="submit" class="btn btn-success"> G </button>
                                </form>
                            </div>    
                        </div>
                        <form method="POST" action="setdistance">
                            <div class="btn-group" role="group" aria-label="Basic example" >
                            <button name="distance" type="submit" class="btn btn-secondary" value="7">0.1 mm</button>
                            <button name="distance" type="submit" class="btn btn-secondary" value="73">1 mm</button>
                            <button name="distance" type="submit" class="btn btn-secondary" value="727">10 mm</button>
                            </div>
                        </form>
                        <h3>Info</h3>
                        <p>Step: {step}</p>
                        <p>LED: {ring_status}</p>
                        <p>Color: {ring_color}</p>
                        
                    </div>
                    </div>  
                </body>
            </html>
            """.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)

Save the Python script

Explanation of the code

We will detail here only the most important points of the source code.

The Python Adafruit NeoPixels library allows you to independently control each LED driven by the WS2812B circuit. Unlike the Arduino version, it is not possible to change the brightness level except to free the resource and re-create a new NeoPixel object. To be able to change the color, you must initialize the auto_write parameter to False.

The D10 pin (SPI bus) is problematic on my Raspberry Pi (unless it comes from the library). There is only pin D12 available for this project.

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

The fill() method allows you to change the color of all the LEDs in the ring. We apply the change by executing the show() method.

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

The HTML interface is handled by a classic Python HTTP server. The HTML interface is using the Bootstrap theme already used with Flask.

Unlike Flask, data binding (automatic state update) is not available. It is therefore necessary to send an updated version of the interface for each update. As this is stored in a character string, you just need to use the format method to substitute the value of each variable.

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

The StreamingHandler() method is used to intercept all POST and GET requests sent to the HTTP server from the browser. For example here, we intercept the UP command to move the vertical axis of the microscope using the Nema 17 motor. The right column shows the writing difference compared to Flask .

Standard Python HTTP Server With 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)

To stop the script, we intercept the key combination Ctrl + C so as to

  • server.shutdown() stop the HTTP server
  • camera.stop_recording() stop the video stream and release the camera
  • ring.fill((0,0,0)) turn off the light ring
  • ring.deinit() free the resources of the NeoPixel object
  • GPIO.cleanup() free the GPIO
  • os._exit(0) quit the Python script

Start the microscope script in administrator mode

To be able to drive the Neopixel LED ring from the Raspberry Pi GPIO, you must start the script in administrator mode by preceding the command with a sudo.

Open the Terminal and go to the directory of the Python script, for example the Document folder

cd /home/pi/Documents

Then run the script by preceding the command with a sudo

sudo python3 nom_du_script.py

The HTML interface of the microscope

The HTML interface displays the video feed from the HQ camera. On the side of the image, there are the UP and DOWN commands to move the Z axis of the stand up and down.

A selector allows you to choose the movement to be made. 0.1mm 1mm or 10mm with each movement. You can modify the source code to change this pre-selection.

The ON / OFF command turns on the light ring. It is possible to change the color of the lighting. Default in white, red, yellow and green.

The settings are displayed below the selector

Examples of magnification obtained with different lighting

By varying the color of the LED lighting, it is possible to highlight certain circuit details (components, tracks, inscriptions, etc.).

White

Red

Green

Yellow

Here is a video demonstration

Photos of an ESP01 at different magnifications by changing the extension tube

Here are some shots at different magnifications of an ESP8266EX installed on an ESP01 development board.

White lighting with 100% brightness.

Extension tube 30m Extension tube 40mm 50mm extension tube
Magnification x18 Magnification x26 Magnification x33

Observation of an RGB LED

Just for fun, here are two shots of an RGB LED of the Neopixel ring used for lighting. The integrated circuit, the connections and each LED source can be viewed very clearly for the color Red, Green and Blue.

Light On Light off

Onion epidermis observation test

Here is a last test carried out with an 85mm extension tube. I replaced the methylene blue with food coloring to highlight the cells in the epidermis. To obtain the cliché, the lighting was placed on the table.

We can distinguish the cells very well but not the nucleus at all (certainly because of the dye used and the magnification too low).

This assembly is still limited for scientific observations.

Observation of the epidermis of an onion.

Display the interface in full screen, kiosk mode

All internet browsers offer a version suitable for display screens. Compared to the F11 key , the Kiosk mode offers the following advantages:

Add a tab to Terminal or open a new Terminal then run the following command

chromium-browser -kiosk 127.0.0.1:8000

Chromium opens in full screen without the address bar and elevators. Super practical for welding under a microscope!

To learn more about Chromium’s kiosk mode, read this tutorial

Click to rate this post!
[Total: 0 Average: 0]
Exit mobile version