Source code for pzp_hardware.thorlabs.camera

# This file is a part of pzp-hardware, a library of laboratory hardware support Pieces
# for the puzzlepiece GUI & automation framework. Check out https://pzp-hardware.readthedocs.io
# Licensed under the Apache License 2.0 - https://github.com/jdranczewski/pzp-hardware/blob/main/LICENSE

r"""
:module_title:`ThorLabs Scientific Camera`

Pieces for interacting with `Thorlabs scientific cameras <https://www.thorlabs.com/navigation.cfm?guide_id=2025>`__
using the `puzzlepiece <https://puzzlepiece.readthedocs.io>`__ framework.

Example usage (see :ref:`getting-started` for more details on using Pieces in general)::

    import puzzlepiece as pzp
    from pzp_hardware.thorlabs import camera

    app = pzp.QApp()
    puzzle = pzp.Puzzle(debug=False)
    puzzle.add_piece("camera", camera.Piece, row=0, column=0)
    puzzle.show()
    app.exec()

Installation
------------
* Install ThorCam (not ThorImageCam) from https://www.thorlabs.com/software_pages/ViewSoftwarePage.cfm?Code=ThorCam
* Locate ``Scientific_Camera_Interfaces.zip``
  (usually in ``C:\Program Files\Thorlabs\Scientific Imaging\Scientific Camera Support``)
* Unzip the file to a convenient location
* In the unzipped folder, go to ``SDK/Python Toolkit`` and install the provided package zip file in your Python
  environment, for example with ``pip install "<path to thorlabs_tsi_camera_python_sdk_package.zip>"``
* In the unzipped folder, locate ``SDK\Native Toolkit\dlls\Native_64_lib`` and copy its full path (starting with ``C:``
  or another drive letter)
* When running the Piece for the first time, you will be asked for the DLL directory - provide the one you copied above.

Requirements
------------
.. pzp_requirements:: pzp_hardware.thorlabs.camera

Available Pieces
----------------
"""

import puzzlepiece as pzp
from puzzlepiece.extras import hardware_tools as pht
import pyqtgraph as pg
from pyqtgraph.Qt import QtWidgets
import numpy as np

pht.requirements(
    {
        "PIL": {
            "pip": "pillow",
            "url": "https://pillow.readthedocs.io/en/stable/installation/basic-installation.html",
        }
    }
)
from PIL import Image

from pzp_hardware.generic.mixins import image_preview


class _Settings(pzp.piece.Popup):
    """
    Popup to handle showing settings (params and actions that are normally hidden).
    """

    def define_params(self):
        self.add_invisible_params()
        return super().define_params()

    def define_actions(self):
        self.add_child_actions(("Take background", "ROI", "Rediscover", "Trigger"))
        return super().define_actions()


[docs] class Base(pzp.Piece): """ Base camera Piece without a preview. Can be used to get images and show settings without an explicit image view in the UI. .. image:: ../images/pzp_hardware.thorlabs.camera.Base.png """ custom_horizontal = True
[docs] def debug_image(self): """ Override to change the image the Piece returns in debug mode. Random numbers by default. Should return a numpy array of shape (1080, 1440), with values between 0 and 1023. """ return np.random.random((1080, 1440)) * 1024
def define_params(self): # region Connecting # Make a parameter for the serial number of the camera @pzp.param.dropdown(self, "serial", "") def get_serials(): if self.puzzle.debug: return None return self.puzzle.globals["tlc_sdk"].discover_available_cameras() @pzp.param.connect(self) def connect(): if self.puzzle.debug: # Do nothing if we're in debug mode return 1 try: # See the Thorlabs camera SDK for the details of the functions that are called here # Also see the setup() method below to see where self._TLCameraSDK came from. self.camera = self.puzzle.globals["tlc_sdk"].open_camera( self.params["serial"].get_value() ) self.camera.image_poll_timeout_ms = 1000 if self["unlimited"].value is not None: self.camera.frames_per_trigger_zero_for_unlimited = not self[ "unlimited" ].value else: self.camera.frames_per_trigger_zero_for_unlimited = 1 return 1 except Exception as e: self.dispose() raise e @pzp.param.disconnect(self) def disconnect(): if self.puzzle.debug: return 0 # Disconnect from the camera self.dispose() return 0 # endregion # region Image, triggering @pzp.param.array(self, "image") @self._ensure_connected @self._ensure_armed def get_image(): if self.puzzle.debug: # If we're in debug mode, we just return random noise image = self.debug_image() roi = self["roi"].value if roi is not None: image = image[roi[1]:roi[3], roi[0]:roi[2]] self.params["frame_N"].set_value(self.params["frame_N"].value+1) else: if not self["unlimited"].value or not self._triggered: self.actions["Trigger"]() frame = self.camera.get_pending_frame_or_null() if frame is None: raise Exception( "Acquisition did not complete within the timeout..." ) # Copy the image we got and save a reference internally image = frame.image_buffer[:, ::-1].copy() self.params["frame_N"].set_value(frame.frame_count) if self.params["sub_background"].get_value(): image = image.astype(np.int16) - self.params["background"].get_value() return image @pzp.param.group("Triggering") @pzp.param.checkbox(self, "unlimited", 0, visible=False) @self._ensure_connected @self._ensure_disarmed def unlimited(value): if self.puzzle.debug: return value self.camera.frames_per_trigger_zero_for_unlimited = not value return value @self["unlimited"].set_getter(self) @self._ensure_connected def unlimited(): if self.puzzle.debug: return True return not self.camera.frames_per_trigger_zero_for_unlimited # Make a checkbox for arming the camera self._triggered = 0 @pzp.param.group("Triggering") @pzp.param.checkbox(self, "armed", 0, visible=False) @self._ensure_connected def armed(value): if self.puzzle.debug: if value: self.params["frame_N"].set_value(-1) return value current_value = self.params["armed"].value if value and not current_value: # Arm and trigger the camera self.camera.arm(self["frame_buffer"].value) self._triggered = 0 return 1 elif not value and current_value: self.camera.disarm() self._triggered = 0 return 0 return current_value @self["armed"].set_getter(self) @self._ensure_connected def armed(): if self.puzzle.debug: return True return self.camera.is_armed pzp.param.spinbox(self, "frame_buffer", 2, visible=False)(None).set_group( "Triggering" ) pzp.param.readout(self, "frame_N", visible=False, _type=int)(None).set_group("Triggering") # endregion # region Exposure # The exposure value can be set - that's what this function does @pzp.param.group("Exposure") @pzp.param.spinbox(self, "exposure", 25.0) @self._ensure_connected def exposure(value): if self.puzzle.debug: return value # If we're connected and not in debug mode, set the exposure self.camera.exposure_time_us = int(value * 1000) # The exposure can also be read from the camera (it stores is internally), # so here we register a 'getter' for the exposure param - a function # called to see what the current exposure value is. @exposure.set_getter(self) @self._ensure_connected def get_exposure(): if self.puzzle.debug: return self.params["exposure"].value or 1 # If we're connected and not in debug mode, return the exposure from the camera return self.camera.exposure_time_us / 1000 @pzp.param.group("Exposure") @pzp.param.spinbox(self, "gain", 0) @self._ensure_connected def gain(self, value): if self.puzzle.debug: return value self.camera.gain = value @gain.set_getter(self) @self._ensure_connected def gain(): if self.puzzle.debug: return self.params["gain"].value or 0 # If we're connected and not in debug mode, return the exposure from the camera return self.camera.gain @pzp.param.group("Exposure") @pzp.param.spinbox(self, "black", 0, visible=False) @self._ensure_connected def black(value): if self.puzzle.debug: return value self.camera.black_level = value @black.set_getter(self) @self._ensure_connected def black(): if self.puzzle.debug: return self.params["black"].value or 0 # If we're connected and not in debug mode, return the exposure from the camera return self.camera.black_level @pzp.param.group("Exposure") @pzp.param.readout(self, "counts", False) def get_counts(): image = self.params["image"].get_value() return np.sum(image) @pzp.param.group("Exposure") @pzp.param.readout(self, "max_counts", False) def get_counts(): image = self.params["image"].get_value() return np.amax(image) # endregion # region ROI and binning # Note: the ROI is defined as a 4-element tuple - (upper_left_x, upper_left_y, lower_right_x, lower_right_y) # the units are in coordinates _prior_ to binning @pzp.param.group("Region of interest") @pzp.param.array(self, "roi", False) @self._ensure_connected def roi(): if self.puzzle.debug: return self["roi"].value if self["roi"].value is not None else [0, 0, 1440, 1080] return self.camera.roi @roi.set_setter(self) @self._ensure_connected @self._ensure_disarmed def roi(value): if self.puzzle.debug: return value self.camera.roi = value @pzp.param.group("Region of interest") @pzp.param.spinbox(self, "bin_x", 1, visible=False) @self._ensure_connected @self._ensure_disarmed def bin_x(value): if self.puzzle.debug: return value self.camera.binx = value @bin_x.set_getter(self) @self._ensure_connected def bin_x(): if self.puzzle.debug: return 1 return self.camera.binx @pzp.param.group("Region of interest") @pzp.param.spinbox(self, "bin_y", 1, visible=False) @self._ensure_connected @self._ensure_disarmed def bin_x(value): if self.puzzle.debug: return value self.camera.biny = value @bin_x.set_getter(self) @self._ensure_connected def bin_x(): if self.puzzle.debug: return 1 return self.camera.biny # endregion pzp.param.checkbox(self, "sub_background", 0, visible=False)(None).set_group( "Background" ) pzp.param.array(self, "background", False)(None).set_group("Background") super().define_params() # MARK: Actions def define_actions(self): @pzp.action.define(self, "Take background", visible=False) def take_background(): background = self.params["image"].get_value() self.params["background"].set_value(background) @pzp.action.define(self, "ROI", visible=False) @self._ensure_connected def roi(): self.open_popup(_ROI_Popup, "Camera Region of Interest") @pzp.action.define(self, "Reset ROI", visible=False) @self._ensure_connected @self._ensure_disarmed def reset_roi(): if self.puzzle.debug: self.params["roi"].set_value([0, 0, 1440, 1080]) return self.params["roi"].set_value( [0, 0, self.camera.roi_range[-2], self.camera.roi_range[-1]] ) @pzp.action.define(self, "Save image") def save_image(filename=None): image = self.params["image"].value if image is None: image = self.params["image"].get_value() if filename is None: filename, _ = QtWidgets.QFileDialog.getSaveFileName( self.puzzle, "Save file as...", ".", "Image files (*.png)" ) Image.fromarray((image // 4).astype(np.uint8)).save(filename) @pzp.action.define(self, "Rediscover", visible=False) def rediscover(): if not self.puzzle.debug: self.params["serial"].input.addItems( self.puzzle.globals["tlc_sdk"].discover_available_cameras() ) @pzp.action.define(self, "Trigger", visible=False) def trigger(): self._triggered = True if not self.puzzle.debug: self.camera.issue_software_trigger() @pzp.action.define(self, "Settings") def settings(): self.open_popup(_Settings, "Camera settings") @pzp.piece.ensurer def _ensure_connected(self): if not self.puzzle.debug and not self.params["connected"].value: raise Exception("Camera not connected") @pzp.piece.ensurer def _ensure_armed(self): if not self.params["armed"].value: self.params["armed"].set_value(1) @pzp.piece.ensurer def _ensure_disarmed(self): if self.params["armed"].value: self.params["armed"].set_value(0) # MARK: API setup def setup(self): # This function is called if not in debug mode to setup the hardware connection API if not self.puzzle.globals.require("tlc_sdk"): # If the SDK has not been set up yet, we set it up here pht.requirements( { "thorlabs_tsi_sdk": { "url": "https://pzp-hardware.readthedocs.io/en/latest/auto/pzp_hardware.thorlabs.camera.html#installation" } } ) dll_directory = r"C:\Program Files\Thorlabs\Scientific Imaging\Scientific Camera Support\Scientific Camera Interfaces\SDK\Native Toolkit\dlls\Native_64_lib" dll_directory = pht.config( "thorcam_dll_directory", default=dll_directory, validator=pht.validator_path_exists, ) pht.add_dll_directory(dll_directory) from thorlabs_tsi_sdk.tl_camera import TLCameraSDK self.puzzle.globals["tlc_sdk"] = TLCameraSDK() def dispose(self): # This function 'disposes' of the camera, effectively disconnecting us if hasattr(self, "camera"): self.camera.dispose() del self.camera def handle_close(self, event): # This function is called when the Puzzle is closed, enabling us to disconnect # from the camera and SDK before the app shuts down if not self.puzzle.debug: # Disconnect from the camera self.dispose() if not self.puzzle.debug and self.puzzle.globals.release("tlc_sdk"): # If we are the last Piece using the SDK, we shut the SDK down self.puzzle.globals["tlc_sdk"].dispose() del self.puzzle.globals["tlc_sdk"]
# MARK: ROI Popup class _ROI_Popup(pzp.piece.Popup): """ Popup for setting the camera's Region of Interest (ROI). """ def define_actions(self): @pzp.action.define(self, "set_roi_from_camera", visible=False) def set_roi_from_camera(self): camera_roi = self.parent_piece.params["roi"].get_value() bin_xy = ( self.parent_piece.params["bin_x"].get_value(), self.parent_piece.params["bin_y"].get_value() ) camera_roi = [camera_roi[i]/bin_xy[i%2] for i in range(4)] self.roi_item.setPos(camera_roi[:2]) self.roi_item.setSize( [camera_roi[2] - camera_roi[0] + 1, camera_roi[3] - camera_roi[1] + 1] ) @pzp.action.define(self, "Capture ref") def capture_reference(self): # reset the ROI to the original one temporarily, and then back to the user setting if not self.puzzle.debug: original_roi = self.parent_piece.params["roi"].get_value() self.parent_piece.params["armed"].set_value(0) self.parent_piece.actions["Reset ROI"]() self.parent_piece.params["armed"].set_value(1) image = self.parent_piece.params["image"].get_value() self.imgw.setImage(image[:, ::-1]) if not self.puzzle.debug: self.parent_piece.params["armed"].set_value(0) self.parent_piece.params["roi"].set_value(original_roi) @pzp.action.define(self, "Set ROI") def set_roi(self): x1, y1 = self.roi_item.pos() x2, y2 = self.roi_item.size() x1, x2 = (x*self.parent_piece["bin_x"].get_value() for x in (x1, x2)) y1, y2 = (y*self.parent_piece["bin_y"].get_value() for y in (y1, y2)) x2 += x1 - 1 y2 += y1 - 1 x1, x2, y1, y2 = (int(np.round(x)) for x in (x1, x2, y1, y2)) x1 = x1 if x1 > 0 else 0 y1 = y1 if y1 > 0 else 0 x2 = x2 if x2 < self._cols - 1 else self._cols - 1 y2 = y2 if y2 < self._rows - 1 else self._rows - 1 self.parent_piece.params["armed"].set_value(0) self.parent_piece.params["roi"].set_value((x1, y1, x2, y2)) self.actions["set_roi_from_camera"]() @pzp.action.define(self, "Centre") def centre_roi(self): pos, size = self.roi_item.pos(), self.roi_item.size() self.roi_item.setPos( (self._cols / 2 - size[0] / 2, self._rows / 2 - size[1] / 2) ) @pzp.action.define(self, "Reset") def reset_roi(self): self.parent_piece.actions["Reset ROI"]() self.actions["set_roi_from_camera"]() def custom_layout(self): layout = QtWidgets.QVBoxLayout() # Make an ImageView self.pw = pg.PlotWidget() layout.addWidget(self.pw) plot_item = self.pw.getPlotItem() plot_item.setAspectLocked(True) plot_item.invertY(True) plot_item.showGrid(True, True) self.imgw = pg.ImageItem(border="w", axisOrder="row-major") plot_item.addItem(self.imgw) # Make a ROI self.roi_item = pg.ROI([0, 0], [10, 10], pen=(255, 255, 0, 200)) self.roi_item.addScaleHandle([0.5, 1], [0.5, 0.5]) self.roi_item.addScaleHandle([1, 0.5], [0.5, 0.5]) # Finish up if self.puzzle.debug: self._cols, self._rows = 1440, 1080 else: self._cols, self._rows = self.parent_piece.camera.roi_range[-2], self.parent_piece.camera.roi_range[-1] self.actions["set_roi_from_camera"]() self.actions["Capture ref"]() self.imgw.update() plot_item.addItem(self.roi_item) return layout # MARK: Main Pieces
[docs] class Piece(image_preview.ImagePreview, Base): """ Like :class:`~pzp-hardware.thorlabs.camera.Base`, but includes a preview for the captured image. Can be made to run live. .. image:: ../images/pzp_hardware.thorlabs.camera.Piece.png """ live_toggle = True autolevel_toggle = True max_counts = 1023
[docs] class LineoutPiece(image_preview.LineoutImagePreview, Base): """ Like :class:`~pzp-hardware.thorlabs.camera.Piece` above, but the preview includes two movable lines (horizontal and vertical), and plots that show the image profile along these lines. These can also act as a crosshair for alignment, and a circle is shown where they cross. .. image:: ../images/pzp_hardware.thorlabs.camera.LineoutPiece.png """ live_toggle = True autolevel_toggle = True max_counts = 1023
if __name__ == "__main__": # If running this file directly, make a Puzzle, add our Piece, and display it app = pzp.QApp() puzzle = pzp.Puzzle(name="Camera", debug=pht.debug_prompt()) puzzle.add_piece("camera", LineoutPiece, 0, 0) puzzle.show() app.exec()