From 4733b250ed8123c1da4eaea1234ca60fae65d608 Mon Sep 17 00:00:00 2001 From: jtc42 Date: Fri, 28 Jun 2019 12:52:55 +0100 Subject: [PATCH] Added support for a fully mocked device --- openflexure_microscope/api/app.py | 11 +- openflexure_microscope/camera/__init__.py | 3 - openflexure_microscope/camera/base.py | 9 - openflexure_microscope/camera/mock.py | 217 ++++++++++++++++++ openflexure_microscope/camera/pi.py | 9 + .../camera_calibration/recalibrate_utils.py | 4 +- openflexure_microscope/plugins/loader.py | 37 +-- .../extensible_serial_instrument.py | 6 + .../stage/sangaboard/sangaboard.py | 2 + poetry.lock | 16 +- pyproject.toml | 2 +- test1.py | 8 + 12 files changed, 283 insertions(+), 41 deletions(-) create mode 100644 openflexure_microscope/camera/mock.py create mode 100644 test1.py diff --git a/openflexure_microscope/api/app.py b/openflexure_microscope/api/app.py index 44e99e7a..f61ca0e5 100644 --- a/openflexure_microscope/api/app.py +++ b/openflexure_microscope/api/app.py @@ -12,14 +12,17 @@ from openflexure_microscope.api.exceptions import JSONExceptionHandler from openflexure_microscope.api.utilities import list_routes from openflexure_microscope import Microscope -from openflexure_microscope.camera.pi import StreamingCamera + +try: + from openflexure_microscope.camera.pi import StreamingCamera +except ImportError: + from openflexure_microscope.camera.mock import StreamingCamera + from openflexure_microscope.stage.sanga import SangaStage from openflexure_microscope.stage.mock import MockStage from openflexure_microscope.camera.capture import build_captures_from_exif - from openflexure_microscope.config import USER_CONFIG_DIR - from openflexure_microscope.api.v1 import blueprints import time @@ -101,7 +104,7 @@ def attach_microscope(): # TODO: Tidy this up. api_stage may be referenced before assignment. Use some form of Maybe monad? try: api_stage = SangaStage() - except (SerialException, OSError) as e: + except Exception as e: logging.error(e) logging.warning("No valid stage hardware found. Falling back to mock stage!") api_stage = MockStage() diff --git a/openflexure_microscope/camera/__init__.py b/openflexure_microscope/camera/__init__.py index 4d370251..e69de29b 100644 --- a/openflexure_microscope/camera/__init__.py +++ b/openflexure_microscope/camera/__init__.py @@ -1,3 +0,0 @@ -__all__ = ['pi', 'base'] - -from . import pi, base diff --git a/openflexure_microscope/camera/base.py b/openflexure_microscope/camera/base.py index 7f0a5e6c..1ea62010 100644 --- a/openflexure_microscope/camera/base.py +++ b/openflexure_microscope/camera/base.py @@ -167,15 +167,6 @@ class BaseCamera(metaclass=ABCMeta): self.stop_worker() logging.info("Closed {}".format(self)) - def wait_for_camera(self, timeout=5): - """Wait for camera object, with 5 second timeout.""" - timeout_time = time.time() + timeout - while not self.camera: - if time.time() > timeout_time: - raise TimeoutError("Timeout waiting for camera") - else: - pass - # START AND STOP WORKER THREAD def start_worker(self, timeout: int = 5) -> bool: diff --git a/openflexure_microscope/camera/mock.py b/openflexure_microscope/camera/mock.py new file mode 100644 index 00000000..6a67d9d2 --- /dev/null +++ b/openflexure_microscope/camera/mock.py @@ -0,0 +1,217 @@ +# -*- coding: utf-8 -*- + +""" +""" + +from __future__ import division + +import io +import time +import numpy as np +from PIL import Image + +import logging + +# Type hinting +from typing import Tuple + +from .base import BaseCamera, CaptureObject + + +# MAIN CLASS +class StreamingCamera(BaseCamera): + + def __init__(self): + # Run BaseCamera init + BaseCamera.__init__(self) + + # Store state of StreamingCamera + self.state.update({ + 'stream_active': False, + 'record_active': False + }) + + # Update config properties + self.image_resolution = (1312, 976) + self.stream_resolution = (832, 624) + self.numpy_resolution = (1312, 976) + self.jpeg_quality = 75 + self.framerate = 10 + + # Create an empty stream + self.stream = io.BytesIO() + + # Start streaming + self.start_worker() + + def initialisation(self): + """Run any initialisation code when the frame iterator starts.""" + pass + + def close(self): + """Close the Raspberry Pi StreamingCamera.""" + # Run BaseCamera close method + BaseCamera.close(self) + + # HANDLE SETTINGS + def read_config(self) -> dict: + """ + Return config dictionary of the StreamingCamera. + """ + + conf_dict = { + 'stream_resolution': self.stream_resolution, + 'image_resolution': self.image_resolution, + 'numpy_resolution': self.numpy_resolution, + 'jpeg_quality': self.jpeg_quality + } + + return conf_dict + + def apply_config(self, config: dict): + """ + Write a config dictionary to the StreamingCamera config. + + The passed dictionary may contain other parameters not relevant to + camera config. Eg. Passing a general config file will work fine. + + Args: + config (dict): Dictionary of config parameters. + """ + # TODO: Include timing and batching logic when applying PiCamera settings + + paused_stream = False + logging.debug("StreamingCamera: Applying config:") + logging.debug(config) + + with self.lock: + + # Apply valid config params to Picamera object + if not self.state['record_active']: # If not recording a video + + # StreamingCamera parameters + for key, value in config.items(): # For each provided setting + if hasattr(self, key): + setattr(self, key, value) + + # If stream was paused to update config, unpause + if paused_stream: + logging.info("Resuming stream.") + self.start_stream_recording() + + else: + raise Exception( + "Cannot update camera config while recording is active.") + + def set_zoom(self, zoom_value: float = 1.) -> None: + """ + Change the camera zoom, handling re-centering and scaling. + """ + logging.warning("Zoom not implemented in mock camera") + + # LAUNCH ACTIONS + + def start_preview(self, fullscreen=True, window=None): + """Start the on board GPU camera preview.""" + logging.warning("GPU preview not implemented in mock camera") + + def stop_preview(self): + """Stop the on board GPU camera preview.""" + logging.warning("GPU preview not implemented in mock camera") + + + def start_recording( + self, + output, + fmt: str = 'h264', + quality: int = 15): + """Start recording. + + Start a new video recording, writing to a output object. + + Args: + output (CaptureObject/str): Output object to write data bytes to. + fmt (str): Format of the capture. + quality (int): Video recording quality. + + Returns: + output_object (str/BytesIO): Target object. + + """ + with self.lock: + # Start recording method only if a current recording is not running + logging.warning("Recording not implemented in mock camera") + + + def stop_recording(self): + """Stop the last started video recording on splitter port 2.""" + with self.lock: + logging.warning("Recording not implemented in mock camera") + + def capture( + self, + output, + fmt: str = 'jpeg', + use_video_port: bool = False, + resize: Tuple[int, int] = None, + bayer: bool = True): + """ + Capture a still image to a StreamObject. + + Defaults to JPEG format. + Target object can be overridden for development purposes. + + Args: + output (CaptureObject/str): Output object to write data bytes to. + use_video_port (bool): Capture from the video port used for streaming. Lower resolution, faster. + fmt (str): Format of the capture. + resize ((int, int)): Resize the captured image. + bayer (bool): Store raw bayer data in capture + """ + + with self.lock: + logging.warning("Capture not implemented in mock camera") + + + def gen_img(self): + imarray = np.random.rand(self.stream_resolution[1], self.stream_resolution[0], 3) * 255 + im = Image.fromarray(imarray.astype('uint8')).convert('L') + im.save(self.stream, format="JPEG") + + # HANDLE STREAM FRAMES + + def frames(self): + """ + Create generator that returns frames from the camera. + + Records video from port 1 to a byte stream, + and iterates sequential frames. + """ + # Run this initialisation method + self.initialisation() + + # Update state + logging.debug("STREAM ACTIVE") + + # While the iterator is not closed + try: + while True: + # reset stream for next frame + self.stream.seek(0) + self.stream.truncate() + # to stream, read the new frame + time.sleep(1 / self.framerate * 0.1) + + # yield the result to be read + self.gen_img() + frame = self.stream.getvalue() + + # ensure the size of package is right + if len(frame) == 0: + pass + else: + yield frame + + # When GeneratorExit or StopIteration raised, run cleanup code + finally: + logging.debug("FRAME ITERATOR END") diff --git a/openflexure_microscope/camera/pi.py b/openflexure_microscope/camera/pi.py index 9b4c7332..18186236 100644 --- a/openflexure_microscope/camera/pi.py +++ b/openflexure_microscope/camera/pi.py @@ -529,6 +529,15 @@ class StreamingCamera(BaseCamera): # HANDLE STREAM FRAMES + def wait_for_camera(self, timeout=5): + """Wait for camera object, with 5 second timeout.""" + timeout_time = time.time() + timeout + while not self.camera: + if time.time() > timeout_time: + raise TimeoutError("Timeout waiting for camera") + else: + pass + def frames(self): """ Create generator that returns frames from the camera. diff --git a/openflexure_microscope/plugins/default/camera_calibration/recalibrate_utils.py b/openflexure_microscope/plugins/default/camera_calibration/recalibrate_utils.py index 34b3567b..4940a77a 100644 --- a/openflexure_microscope/plugins/default/camera_calibration/recalibrate_utils.py +++ b/openflexure_microscope/plugins/default/camera_calibration/recalibrate_utils.py @@ -1,8 +1,8 @@ import numpy as np -from picamera import PiCamera -from picamera.array import PiRGBArray, PiBayerArray import time +from picamera import PiCamera +from picamera.array import PiRGBArray, PiBayerArray def rgb_image(camera, resize=None, **kwargs): """Capture an image and return an RGB numpy array""" diff --git a/openflexure_microscope/plugins/loader.py b/openflexure_microscope/plugins/loader.py index 74b40a70..d484b4ee 100644 --- a/openflexure_microscope/plugins/loader.py +++ b/openflexure_microscope/plugins/loader.py @@ -78,8 +78,13 @@ def load_plugin_module(plugin_path): # If the loader was found (i.e. plugin probably exists) if check_module(plugin_path): - plugin_module = importlib.import_module(plugin_path) - plugin_name = name_from_module(plugin_path) + try: + plugin_module = importlib.import_module(plugin_path) + plugin_name = name_from_module(plugin_path) + except Exception as e: + logging.error("Error loading plugin.") + logging.error(e) + return None, None # If no loader was found, try finding a file from path else: @@ -156,24 +161,28 @@ class PluginMount(object): """ plugin_class, plugin_name = class_from_map(plugin_map) - pythonsafe_plugin_name = plugin_name.replace("/", "_") + if plugin_class is not None: - if plugin_class and plugin_name: - plugin_object = plugin_class() + pythonsafe_plugin_name = plugin_name.replace("/", "_") - if hasattr(self, plugin_name): # If a plugin with the same name is already attached. - logging.warning(ConColors.WARNING + "A plugin named {} has already been loaded. Skipping {}.".format(plugin_name, plugin_map) + ConColors.ENDC) + if plugin_class and plugin_name: + plugin_object = plugin_class() - elif isinstance(plugin_object, MicroscopePlugin): # If plugin_object is an instance of MicroscopePlugin - # Attach plugin_object to the plugin mount - setattr(self, pythonsafe_plugin_name, plugin_object) - self.plugins.append((plugin_name, plugin_object)) + if hasattr(self, plugin_name): # If a plugin with the same name is already attached. + logging.warning(ConColors.WARNING + "A plugin named {} has already been loaded. Skipping {}.".format(plugin_name, plugin_map) + ConColors.ENDC) - # Grant plugin access to the hardware - plugin_object.microscope = self.parent + elif isinstance(plugin_object, MicroscopePlugin): # If plugin_object is an instance of MicroscopePlugin + # Attach plugin_object to the plugin mount + setattr(self, pythonsafe_plugin_name, plugin_object) + self.plugins.append((plugin_name, plugin_object)) - logging.info(ConColors.OKGREEN + "Plugin {} loaded as {}.".format(plugin_map, plugin_name) + ConColors.ENDC) + # Grant plugin access to the hardware + plugin_object.microscope = self.parent + logging.info(ConColors.OKGREEN + "Plugin {} loaded as {}.".format(plugin_map, plugin_name) + ConColors.ENDC) + + else: + logging.error("Error loading plugin. Moving on.") class MicroscopePlugin: """ diff --git a/openflexure_microscope/stage/sangaboard/extensible_serial_instrument.py b/openflexure_microscope/stage/sangaboard/extensible_serial_instrument.py index 0e6df7d7..5bc6b819 100644 --- a/openflexure_microscope/stage/sangaboard/extensible_serial_instrument.py +++ b/openflexure_microscope/stage/sangaboard/extensible_serial_instrument.py @@ -28,6 +28,7 @@ from serial import STOPBITS_ONE, STOPBITS_ONE_POINT_FIVE, STOPBITS_TWO import io import time import warnings +import logging class ExtensibleSerialInstrument(object): """ @@ -58,8 +59,11 @@ class ExtensibleSerialInstrument(object): """ Set up the serial port and so on. """ + logging.info("Updating BSI port settings") self.port_settings.update(kwargs) + logging.info("Opening BSI connection to port {}".format(port)) self.open(port, False) # Eventually this shouldn't rely on init... + logging.info("Opened BSI connection to port {}".format(port)) def open(self, port=None, quiet=True): """Open communications with the serial port. @@ -81,11 +85,13 @@ class ExtensibleSerialInstrument(object): def close(self): """Release the serial port""" + logging.debug("Closing serial connection") with self.communications_lock: try: self._ser.close() except Exception as e: print("The serial port didn't close cleanly:", e) + logging.debug("Connection closed") def __del__(self): """Close the port when the object is deleted diff --git a/openflexure_microscope/stage/sangaboard/sangaboard.py b/openflexure_microscope/stage/sangaboard/sangaboard.py index ad30e105..ca88922e 100644 --- a/openflexure_microscope/stage/sangaboard/sangaboard.py +++ b/openflexure_microscope/stage/sangaboard/sangaboard.py @@ -12,6 +12,7 @@ import time from .extensible_serial_instrument import ExtensibleSerialInstrument, OptionalModule, QueriedProperty, EIGHTBITS, PARITY_NONE, STOPBITS_ONE import re import warnings +import logging class Sangaboard(ExtensibleSerialInstrument): """Class managing serial communications with a Sangaboard @@ -68,6 +69,7 @@ class Sangaboard(ExtensibleSerialInstrument): """ super(Sangaboard, self).__init__(port, **kwargs) try: + logging.debug("Running firmware checks") # Request firmware version from the board self.firmware = self.query("version",timeout=2).rstrip() # The slightly complicated regexp below will match the version string, diff --git a/poetry.lock b/poetry.lock index 6d428019..c3251caf 100644 --- a/poetry.lock +++ b/poetry.lock @@ -255,11 +255,11 @@ category = "main" description = "SciPy: Scientific Library for Python" name = "scipy" optional = false -python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*" -version = "1.2.1" +python-versions = ">=3.5" +version = "1.3.0" [package.dependencies] -numpy = ">=1.8.2" +numpy = ">=1.13.3" [[package]] category = "main" @@ -271,11 +271,11 @@ version = "1.12.0" [[package]] category = "dev" -description = "This package provides 16 stemmer algorithms (15 + Poerter English stemmer) generated from Snowball algorithms." +description = "This package provides 23 stemmers for 22 languages generated from Snowball algorithms." name = "snowballstemmer" optional = false python-versions = "*" -version = "1.2.1" +version = "1.9.0" [[package]] category = "dev" @@ -381,7 +381,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" version = "0.15.4" [metadata] -content-hash = "14a52bee3012c7880e4c87e940639606eb88ad7320e262d5228566b6df1ef601" +content-hash = "ec0d5aa30422b8f44fb92e8e818120d4f0e1b084773594ce2531e31db5af26f4" python-versions = "^3.5" [metadata.hashes] @@ -412,9 +412,9 @@ pytz = ["303879e36b721603cc54604edcac9d20401bdbe31e1e4fdee5b9f98d5d31dfda", "d74 pyyaml = ["3d7da3009c0f3e783b2c873687652d83b1bbfd5c88e9813fb7e5b03c0dd3108b", "3ef3092145e9b70e3ddd2c7ad59bdd0252a94dfe3949721633e41344de00a6bf", "40c71b8e076d0550b2e6380bada1f1cd1017b882f7e16f09a65be98e017f211a", "558dd60b890ba8fd982e05941927a3911dc409a63dcb8b634feaa0cda69330d3", "a7c28b45d9f99102fa092bb213aa12e0aaf9a6a1f5e395d36166639c1f96c3a1", "aa7dd4a6a427aed7df6fb7f08a580d68d9b118d90310374716ae90b710280af1", "bc558586e6045763782014934bfaf39d48b8ae85a2713117d16c39864085c613", "d46d7982b62e0729ad0175a9bc7e10a566fc07b224d2c79fafb5e032727eaa04", "d5eef459e30b09f5a098b9cea68bebfeb268697f78d647bd255a085371ac7f3f", "e01d3203230e1786cd91ccfdc8f8454c8069c91bee3962ad93b87a4b2860f537", "e170a9e6fcfd19021dd29845af83bb79236068bf5fd4df3327c1be18182b2531"] requests = ["11e007a8a2aa0323f5a921e9e6a2d7e4e67d9877e85773fba9ba6419025cbeb4", "9cf5292fcd0f598c671cfc1e0d7d1a7f13bb8085e9a590f48c010551dc6c4b31"] "rpi.gpio" = ["a4210ad63bfe844e43995286de0d3950dfacfa0f3799bb9392770ac54a7d2e47"] -scipy = ["014cb900c003b5ac81a53f2403294e8ecf37aedc315b59a6b9370dce0aa7627a", "281a34da34a5e0de42d26aed692ab710141cad9d5d218b20643a9cb538ace976", "588f9cc4bfab04c45fbd19c1354b5ade377a8124d6151d511c83730a9b6b2338", "5a10661accd36b6e2e8855addcf3d675d6222006a15795420a39c040362def66", "628f60be272512ca1123524969649a8cb5ae8b31cca349f7c6f8903daf9034d7", "6dcc43a88e25b815c2dea1c6fac7339779fc988f5df8396e1de01610604a7c38", "70e37cec0ac0fe95c85b74ca4e0620169590fd5d3f44765f3c3a532cedb0e5fd", "7274735fb6fb5d67d3789ddec2cd53ed6362539b41aa6cc0d33a06c003aaa390", "78e12972e144da47326958ac40c2bd1c1cca908edc8b01c26a36f9ffd3dce466", "790cbd3c8d09f3a6d9c47c4558841e25bac34eb7a0864a9def8f26be0b8706af", "79792c8fe8e9d06ebc50fe23266522c8c89f20aa94ac8e80472917ecdce1e5ba", "865afedf35aaef6df6344bee0de391ee5e99d6e802950a237f9fb9b13e441f91", "870fd401ec7b64a895cff8e206ee16569158db00254b2f7157b4c9a5db72c722", "963815c226b29b0176d5e3d37fc9de46e2778ce4636a5a7af11a48122ef2577c", "9726791484f08e394af0b59eb80489ad94d0a53bbb58ab1837dcad4d58489863", "9de84a71bb7979aa8c089c4fb0ea0e2ed3917df3fb2a287a41aaea54bbad7f5d", "b2c324ddc5d6dbd3f13680ad16a29425841876a84a1de23a984236d1afff4fa6", "b86ae13c597fca087cb8c193870507c8916cefb21e52e1897da320b5a35075e5", "ba0488d4dbba2af5bf9596b849873102d612e49a118c512d9d302ceafa36e01a", "d78702af4102a3a4e23bb7372cec283e78f32f5573d92091aa6aaba870370fe1", "def0e5d681dd3eb562b059d355ae8bebe27f5cc455ab7c2b6655586b63d3a8ea", "e085d1babcb419bbe58e2e805ac61924dac4ca45a07c9fa081144739e500aa3c", "e2cfcbab37c082a5087aba5ff00209999053260441caadd4f0e8f4c2d6b72088", "e742f1f5dcaf222e8471c37ee3d1fd561568a16bb52e031c25674ff1cf9702d5", "f06819b028b8ef9010281e74c59cb35483933583043091ed6b261bb1540f11cc", "f15f2d60a11c306de7700ee9f65df7e9e463848dbea9c8051e293b704038da60", "f31338ee269d201abe76083a990905473987371ff6f3fdb76a3f9073a361cf37", "f6b88c8d302c3dac8dff7766955e38d670c82e0d79edfc7eae47d6bb2c186594"] +scipy = ["03b1e0775edbe6a4c64effb05fff2ce1429b76d29d754aa5ee2d848b60033351", "09d008237baabf52a5d4f5a6fcf9b3c03408f3f61a69c404472a16861a73917e", "10325f0ffac2400b1ec09537b7e403419dcd25d9fee602a44e8a32119af9079e", "1db9f964ed9c52dc5bd6127f0dd90ac89791daa690a5665cc01eae185912e1ba", "409846be9d6bdcbd78b9e5afe2f64b2da5a923dd7c1cd0615ce589489533fdbb", "4907040f62b91c2e170359c3d36c000af783f0fa1516a83d6c1517cde0af5340", "6c0543f2fdd38dee631fb023c0f31c284a532d205590b393d72009c14847f5b1", "826b9f5fbb7f908a13aa1efd4b7321e36992f5868d5d8311c7b40cf9b11ca0e7", "a7695a378c2ce402405ea37b12c7a338a8755e081869bd6b95858893ceb617ae", "a84c31e8409b420c3ca57fd30c7589378d6fdc8d155d866a7f8e6e80dec6fd06", "adadeeae5500de0da2b9e8dd478520d0a9945b577b2198f2462555e68f58e7ef", "b283a76a83fe463c9587a2c88003f800e08c3929dfbeba833b78260f9c209785", "c19a7389ab3cd712058a8c3c9ffd8d27a57f3d84b9c91a931f542682bb3d269d", "c3bb4bd2aca82fb498247deeac12265921fe231502a6bc6edea3ee7fe6c40a7a", "c5ea60ece0c0c1c849025bfc541b60a6751b491b6f11dd9ef37ab5b8c9041921", "db61a640ca20f237317d27bc658c1fc54c7581ff7f6502d112922dc285bdabee"] six = ["3350809f0555b11f552448330d0b52d5f24c91a322ea4a15ef22629740f3761c", "d16a0141ec1a18405cd4ce8b4613101da75da0e9a7aec5bdd4fa804d0e0eba73"] -snowballstemmer = ["919f26a68b2c17a7634da993d91339e288964f93c274f1343e3bbbe2096e1128", "9f3bcd3c401c3e862ec0ebe6d2c069ebc012ce142cce209c098ccb5b09136e89"] +snowballstemmer = ["9f3b9ffe0809d174f7047e121431acf99c89a7040f0ca84f94ba53a498e6d0c9"] sphinx = ["22538e1bbe62b407cf5a8aabe1bb15848aa66bb79559f42f5202bbce6b757a69", "f9a79e746b87921cabc3baa375199c6076d1270cee53915dbd24fdbeaaacc427"] sphinxcontrib-applehelp = ["edaa0ab2b2bc74403149cb0209d6775c96de797dfd5b5e2a71981309efab3897", "fb8dee85af95e5c30c91f10e7eb3c8967308518e0f7488a2828ef7bc191d0d5d"] sphinxcontrib-devhelp = ["6c64b077937330a9128a4da74586e8c2130262f014689b4b89e2d08ee7294a34", "9512ecb00a2b0821a146736b39f7aeb90759834b07e81e8cc23a9c70bacb9981"] diff --git a/pyproject.toml b/pyproject.toml index 1e4002c5..2ddfbc4d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,12 +28,12 @@ Flask = "^1.0" pyyaml = "^3.13" numpy = "1.16.4" # Exact version until we can guarantee a wheel Pillow = "^5.4" -"RPi.GPIO" = "^0.6.5" openflexure-stage = "^0.2.1" flask-cors = "^3.0" scipy = "1.3.0" # Exact version until we can guarantee a wheel picamera = { git = "https://github.com/rwb27/picamera.git", branch = "lens-shading" } # Lens shading requires >1.14 or RWB fork, lens-shading branch. +"RPi.GPIO" = { version = "^0.6.5", optional = true } [tool.poetry.dev-dependencies] sphinxcontrib-httpdomain = "^1.7" diff --git a/test1.py b/test1.py new file mode 100644 index 00000000..80d3d186 --- /dev/null +++ b/test1.py @@ -0,0 +1,8 @@ +from openflexure_microscope.stage.sangaboard import Sangaboard + +import logging +import sys + +logging.basicConfig(stream=sys.stdout, level=logging.DEBUG) + +stage = Sangaboard() \ No newline at end of file