diff --git a/.gitattributes b/.gitattributes deleted file mode 100644 index e69de29b..00000000 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 00000000..5d5425e6 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,11 @@ +repos: + - repo: https://github.com/psf/black + rev: 19.10b0 + hooks: + - id: black + - repo: https://github.com/timothycrosley/isort + rev: 5.4.2 + hooks: + - id: isort + additional_dependencies: [toml] + exclude: ^.*/?setup\.py$ diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index c9cbf4ad..00000000 --- a/Dockerfile +++ /dev/null @@ -1,20 +0,0 @@ -FROM python:3.6 - -ENV PYTHONUNBUFFERED=1 \ - POETRY_VERSION=1.0.3 \ - POETRY_HOME="/opt/poetry" \ - POETRY_VIRTUALENVS_IN_PROJECT=true \ - POETRY_NO_INTERACTION=1 - -ENV PATH="$POETRY_HOME/bin:$VENV_PATH/bin:$PATH" - -WORKDIR /app - -RUN curl -sSL https://raw.githubusercontent.com/sdispater/poetry/master/get-poetry.py | python - -COPY . ./ - -RUN poetry install --no-dev --no-interaction - -EXPOSE 5000 -CMD ["poetry", "run", "python", "-m", "openflexure_microscope.api.app"] diff --git a/README.md b/README.md index e3327bce..55b83c66 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,17 @@ This includes installing the server in a mode better suited for active developme * `poetry install` * `poetry run build_static` +### Pre-commit hooks + +We make use of pre-commit hooks to run code analysis before committing to the codebase. + +The simplest way to ensure this works is to install pre-commits into your current Python environment: + +* `pip3 install pre-commit` +* `pre-commit install` + +To run pre-commit analysis manually, you can run `pre-commit run`. + ### Node installation * Note, building the static interface will require a valid Node.js installation diff --git a/docs/source/conf.py b/docs/source/conf.py index fe3f927f..4fd4b0c0 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -38,7 +38,7 @@ sys.modules.update((mod_name, Mock()) for mod_name in mock_imports) # -- Project information ----------------------------------------------------- project = "OpenFlexure Microscope Software" -copyright = "2018, Bath Open Instrumentation Group" +copyright = "2018, Bath Open Instrumentation Group" # pylint: disable=redefined-builtin author = "Bath Open Instrumentation Group" # The short X.Y version diff --git a/docs/source/extensions/example_extension/04_properties.py b/docs/source/extensions/example_extension/04_properties.py index ec13cc3c..3bedbad0 100644 --- a/docs/source/extensions/example_extension/04_properties.py +++ b/docs/source/extensions/example_extension/04_properties.py @@ -1,6 +1,6 @@ from labthings import Schema, fields, find_component from labthings.extensions import BaseExtension -from labthings.views import PropertyView, View +from labthings.views import PropertyView ## Extension methods diff --git a/docs/source/extensions/example_extension/05_actions.py b/docs/source/extensions/example_extension/05_actions.py index 3980e531..52b1b024 100644 --- a/docs/source/extensions/example_extension/05_actions.py +++ b/docs/source/extensions/example_extension/05_actions.py @@ -3,7 +3,7 @@ import io # Used in our capture action from flask import send_file # Used to send images from our server from labthings import Schema, fields, find_component from labthings.extensions import BaseExtension -from labthings.views import ActionView, PropertyView, View +from labthings.views import ActionView, PropertyView ## Extension methods diff --git a/docs/source/extensions/example_extension/06_tasks_locks.py b/docs/source/extensions/example_extension/06_tasks_locks.py index e17ac971..710b4e73 100644 --- a/docs/source/extensions/example_extension/06_tasks_locks.py +++ b/docs/source/extensions/example_extension/06_tasks_locks.py @@ -1,16 +1,8 @@ -import io # Used in our capture action import time # Used in our timelapse function -from flask import send_file # Used to send images from our server -from labthings import ( - Schema, - current_action, - fields, - find_component, - update_action_progress, -) +from labthings import current_action, fields, find_component, update_action_progress from labthings.extensions import BaseExtension -from labthings.views import ActionView, PropertyView, View +from labthings.views import ActionView # Used in our timelapse function from openflexure_microscope.captures.capture_manager import generate_basename diff --git a/docs/source/extensions/example_extension/07_ev_gui.py b/docs/source/extensions/example_extension/07_ev_gui.py index ea2c9c9b..a71d9053 100644 --- a/docs/source/extensions/example_extension/07_ev_gui.py +++ b/docs/source/extensions/example_extension/07_ev_gui.py @@ -1,16 +1,8 @@ -import io # Used in our capture action import time # Used in our timelapse function -from flask import send_file # Used to send images from our server -from labthings import ( - Schema, - current_action, - fields, - find_component, - update_action_progress, -) +from labthings import current_action, fields, find_component, update_action_progress from labthings.extensions import BaseExtension -from labthings.views import ActionView, PropertyView, View +from labthings.views import ActionView # Used to convert our GUI dictionary into a complete eV extension GUI from openflexure_microscope.api.utilities.gui import build_gui diff --git a/openflexure_microscope/api/app.py b/openflexure_microscope/api/app.py index 26d7f5c1..ebb77d19 100644 --- a/openflexure_microscope/api/app.py +++ b/openflexure_microscope/api/app.py @@ -24,13 +24,13 @@ import pkg_resources from flask import abort, send_file from flask_cors import CORS, cross_origin from labthings import create_app -from labthings.views import View from labthings.extensions import find_extensions +from labthings.views import View from openflexure_microscope.api.microscope import default_microscope as api_microscope from openflexure_microscope.api.utilities import init_default_extensions, list_routes from openflexure_microscope.api.v2 import views -from openflexure_microscope.config import JSONEncoder +from openflexure_microscope.json import JSONEncoder from openflexure_microscope.paths import ( OPENFLEXURE_EXTENSIONS_PATH, OPENFLEXURE_VAR_PATH, diff --git a/openflexure_microscope/api/default_extensions/autofocus.py b/openflexure_microscope/api/default_extensions/autofocus.py index 507a9fac..a9e3f0e4 100644 --- a/openflexure_microscope/api/default_extensions/autofocus.py +++ b/openflexure_microscope/api/default_extensions/autofocus.py @@ -83,7 +83,7 @@ class JPEGSharpnessMonitor: raise e if stop < 1: stop = len(jpeg_times) - logging.debug("changing stop to {}".format(stop)) + logging.debug("changing stop to %s", (stop)) jpeg_times = jpeg_times[start:stop] jpeg_zs = np.interp(jpeg_times, stage_times, stage_zs) return jpeg_times, jpeg_zs, jpeg_sizes[start:stop] @@ -258,13 +258,12 @@ def fast_up_down_up_autofocus( # Ensure the MJPEG stream has started microscope.camera.start_stream_recording() - df = dz # TODO: refactor so I actually use dz in the code below! logging.debug("Initial move") if initial_move_up: - m.focus_rel(df / 2) + m.focus_rel(dz / 2) # move down logging.debug("Move down") - i, z = m.focus_rel(-df) + i, z = m.focus_rel(-dz) # now inspect where the sharpest point is, and estimate the sharpness # (JPEG size) that we should find at the start of the Z stack _, jz, js = m.move_data(i) @@ -285,16 +284,14 @@ def fast_up_down_up_autofocus( inow = np.argmax( js < current_js ) # use the curve we recorded to estimate our position - # TODO: fancy interpolation stuff # So, the Z position corresponding to our current sharpness value is zs[inow] # That means we should move forwards, by best_z - zs[inow] logging.debug("Correction move") correction_move = best_z + target_z - jz[inow] logging.debug( - "Fast autofocus scan: correcting backlash by moving {} steps".format( - correction_move - ) + "Fast autofocus scan: correcting backlash by moving %s steps", + (correction_move), ) m.focus_rel(correction_move) return m.data_dict() diff --git a/openflexure_microscope/api/default_extensions/picamera_autocalibrate/recalibrate_utils.py b/openflexure_microscope/api/default_extensions/picamera_autocalibrate/recalibrate_utils.py index 28b04c02..fd18cf46 100644 --- a/openflexure_microscope/api/default_extensions/picamera_autocalibrate/recalibrate_utils.py +++ b/openflexure_microscope/api/default_extensions/picamera_autocalibrate/recalibrate_utils.py @@ -63,17 +63,15 @@ def auto_expose_and_freeze_settings(camera): logging.info("Freezing the camera settings...") camera.shutter_speed = camera.exposure_speed - logging.info("Shutter speed = {}".format(camera.shutter_speed)) + logging.info("Shutter speed = %s", (camera.shutter_speed)) camera.exposure_mode = "off" logging.info("Auto exposure disabled") g = camera.awb_gains camera.awb_mode = "off" camera.awb_gains = g - logging.info("Auto white balance disabled, gains are {}".format(g)) + logging.info("Auto white balance disabled, gains are %s", (g)) logging.info( - "Analogue gain: {}, Digital gain: {}".format( - camera.analog_gain, camera.digital_gain - ) + "Analogue gain: %s, Digital gain: %s", camera.analog_gain, camera.digital_gain ) adjust_exposure_to_setpoint(camera, 215) @@ -100,7 +98,7 @@ def lst_from_channels(channels): # lst_resolution = list(np.ceil(full_resolution / 64.0).astype(int)) lst_resolution = [(r // 64) + 1 for r in full_resolution] # NB the size of the LST is 1/64th of the image, but rounded UP. - logging.info("Generating a lens shading table at {}x{}".format(*lst_resolution)) + logging.info("Generating a lens shading table at %sx%s", *lst_resolution) lens_shading = np.zeros([channels.shape[0]] + lst_resolution, dtype=np.float) for i in range(lens_shading.shape[0]): image_channel = channels[i, :, :] @@ -118,9 +116,12 @@ def lst_from_channels(channels): image_channel, [(0, lw * 32 - iw), (0, lh * 32 - ih)], mode="edge" ) # Pad image to the right and bottom logging.info( - "Channel shape: {}x{}, shading table shape: {}x{}, after padding {}".format( - iw, ih, lw * 32, lh * 32, padded_image_channel.shape - ) + "Channel shape: %sx%s, shading table shape: %sx%s, after padding %s", + iw, + ih, + lw * 32, + lh * 32, + padded_image_channel.shape, ) # Next, fill the shading table (except edge pixels). Please excuse the # for loop - I know it's not fast but this code needn't be! diff --git a/openflexure_microscope/api/default_extensions/scan.py b/openflexure_microscope/api/default_extensions/scan.py index 9a292f11..544d8017 100644 --- a/openflexure_microscope/api/default_extensions/scan.py +++ b/openflexure_microscope/api/default_extensions/scan.py @@ -16,6 +16,7 @@ from labthings import ( from labthings.extensions import BaseExtension from labthings.views import ActionView +from openflexure_microscope.api.v2.views.actions.camera import FullCaptureArgs from openflexure_microscope.captures.capture_manager import generate_basename from openflexure_microscope.devel import abort @@ -216,7 +217,7 @@ class ScanExtension(BaseExtension): for x_y in line: # Move to new grid position without changing z - logging.debug("Moving to step {}".format([x_y[0], x_y[1], next_z])) + logging.debug("Moving to step %s", ([x_y[0], x_y[1], next_z])) microscope.stage.move_abs([x_y[0], x_y[1], next_z]) # Refocus if autofocus_enabled: @@ -270,7 +271,7 @@ class ScanExtension(BaseExtension): # Make sure we use our current best estimate of focus (i.e. the current position) next point next_z = microscope.stage.position[2] - logging.debug("Returning to {}".format(initial_position)) + logging.debug("Returning to %s", (initial_position)) microscope.stage.move_abs(initial_position) end = time.time() @@ -328,34 +329,29 @@ class ScanExtension(BaseExtension): return if i != steps - 1: - logging.debug("Moving z by {}".format(step_size)) + logging.debug("Moving z by %s", (step_size)) microscope.stage.move_rel([0, 0, step_size]) if return_to_start: - logging.debug("Returning to {}".format(initial_position)) + logging.debug("Returning to %s", (initial_position)) microscope.stage.move_abs(initial_position) scan_extension_v2 = ScanExtension() +class TileScanArgs(FullCaptureArgs): + namemode = fields.String(missing="coordinates", example="coordinates") + grid = fields.List(fields.Integer, missing=[3, 3, 3], example=[3, 3, 3]) + style = fields.String(missing="raster") + autofocus_dz = fields.Integer(missing=50) + fast_autofocus = fields.Boolean(missing=False) + stride_size = fields.List( + fields.Integer, missing=[2000, 1500, 100], example=[2000, 1500, 100] + ) + + class TileScanAPI(ActionView): - args = { - "filename": fields.String(missing=None, example=None), - "namemode": fields.String(missing="coordinates", example="coordinates"), - "temporary": fields.Boolean(missing=False), - "stride_size": fields.List( - fields.Integer, missing=[2000, 1500, 100], example=[2000, 1500, 100] - ), - "grid": fields.List(fields.Integer, missing=[3, 3, 3], example=[3, 3, 3]), - "style": fields.String(missing="raster"), - "autofocus_dz": fields.Integer(missing=50), - "fast_autofocus": fields.Boolean(missing=False), - "use_video_port": fields.Boolean(missing=False), - "bayer": fields.Boolean(missing=False), - "annotations": fields.Dict(missing={}, example={"Foo": "Bar"}), - "tags": fields.List(fields.String, missing=[]), - "resize": fields.Dict(missing=None), # TODO: Validate keys - } + args = TileScanArgs() # Allow 10 seconds to stop upon DELETE request # Gives fast-autofocus time to finish if it's running diff --git a/openflexure_microscope/api/default_extensions/zip_builder.py b/openflexure_microscope/api/default_extensions/zip_builder.py index 42470901..5db25a70 100644 --- a/openflexure_microscope/api/default_extensions/zip_builder.py +++ b/openflexure_microscope/api/default_extensions/zip_builder.py @@ -11,8 +11,6 @@ from labthings.schema import Schema, pre_dump from labthings.utilities import description_from_view from labthings.views import ActionView, PropertyView, View -from openflexure_microscope.devel import JsonResponse, request - class ZipObjectSchema(Schema): id = fields.String() @@ -128,12 +126,15 @@ default_zip_manager = ZipManager() class ZipBuilderAPIView(ActionView): - def post(self): - ids = list(JsonResponse(request).json) + args = fields.List(fields.String(), required=True) + + def post(self, args): microscope = find_component("org.openflexure.microscope") # Return a handle on the autofocus task - return default_zip_manager.marshaled_build_zip_from_capture_ids(microscope, ids) + return default_zip_manager.marshaled_build_zip_from_capture_ids( + microscope, args + ) class ZipListAPIView(PropertyView): diff --git a/openflexure_microscope/api/static/src/App.vue b/openflexure_microscope/api/static/src/App.vue index 09ccbb4f..320b0384 100644 --- a/openflexure_microscope/api/static/src/App.vue +++ b/openflexure_microscope/api/static/src/App.vue @@ -184,7 +184,6 @@ export default { this.checkConnection(); // Handle guided tour // If the user has already completed or skipped the guided tour - // TODO: Only run this if connected to the API var completedTour = this.getLocalStorageObj("completedTour") || false; if (!completedTour) { this.$tours["guidedTour"].start(); @@ -207,8 +206,6 @@ export default { ); // Keyboard shortcuts - - // TODO: Shortcut guide Mousetrap.bind("?", () => { console.log(this.keyboardManual); this.toggleModalElement(this.$refs["keyboardManualModal"]); // Calls the mixin diff --git a/openflexure_microscope/api/static/src/components/appContent.vue b/openflexure_microscope/api/static/src/components/appContent.vue index 51e3cd0f..6410db42 100644 --- a/openflexure_microscope/api/static/src/components/appContent.vue +++ b/openflexure_microscope/api/static/src/components/appContent.vue @@ -16,63 +16,27 @@ id="switcher-left" class="uk-flex uk-flex-column uk-padding-remove uk-width-auto uk-height-1-1 uk-text-center" > - - visibility - - - - photo_library - - -
- - - gamepad - - - camera_alt - - - @@ -252,14 +165,69 @@ export default { return { plugins: [], currentTab: "view", - unwatchStoreFunction: null + unwatchStoreFunction: null, + topTabs: [ + { + id: "view", + icon: "visibility", + component: viewContent + }, + { + id: "gallery", + icon: "photo_library", + component: galleryContent, + divide: true // Add a divider after this tab icon + }, + { + id: "navigate", + icon: "gamepad", + component: navigateContent + }, + { + id: "capture", + icon: "camera_alt", + component: captureContent, + divide: true // Add a divider after this tab icon + } + ], + bottomTabs: [ + { + id: "settings", + icon: "settings", + component: settingsContent, + class: "uk-margin-auto-top" + }, + { + id: "logging", + icon: "assignment_late", + component: loggingContent + }, + { + id: "about", + icon: "info", + component: aboutContent + } + ] }; }, computed: { + enabledTopTabs: function() { + var enabledTabs = this.topTabs; + if (this.$store.state.globalSettings.IHIEnabled) { + enabledTabs.push({ + id: "slidescan", + icon: "settings_overscan", + component: slideScanContent + }); + } + return enabledTabs; + }, + pluginsUri: function() { return `${this.$store.getters.baseUri}/api/v2/extensions`; }, + pluginsGuiList: function() { // List of plugin GUIs, obtained from this.plugins values console.log("Recalculating plugins"); @@ -271,15 +239,21 @@ export default { } return pluginGuis; }, + tabOrder: function() { - // TODO: There must be a better way to do this, somehow reading the order of the HTML elements? - var ind = ["view", "gallery", "navigate", "capture", "settings"]; + var ind = []; + for (const tab of this.enabledTopTabs) { + ind.push(tab.id); + } for (const plugin of this.pluginsGuiList) { ind.push(plugin.id); } - ind.push("about"); + for (const tab of this.bottomTabs) { + ind.push(tab.id); + } return ind; }, + currentTabIndex: function() { return this.tabOrder.indexOf(this.currentTab); } diff --git a/openflexure_microscope/api/static/src/components/pluginComponents/JsonForm.vue b/openflexure_microscope/api/static/src/components/pluginComponents/JsonForm.vue index 70262bd8..d53e9eb1 100644 --- a/openflexure_microscope/api/static/src/components/pluginComponents/JsonForm.vue +++ b/openflexure_microscope/api/static/src/components/pluginComponents/JsonForm.vue @@ -131,7 +131,6 @@ export default { }, submitApiUri: function() { - // TODO: This could probably be handled more explicitally return this.pluginApiUri + this.route; } }, diff --git a/openflexure_microscope/api/utilities/__init__.py b/openflexure_microscope/api/utilities/__init__.py index 6e01e9dc..7c12b2a9 100644 --- a/openflexure_microscope/api/utilities/__init__.py +++ b/openflexure_microscope/api/utilities/__init__.py @@ -24,54 +24,6 @@ def blueprint_name_for_module(module_name, api_ver=2, suffix=""): return f"v{api_ver}_{bp_name}_blueprint{suffix}" -class JsonResponse: - def __init__(self, request): - """ - Object to wrap up simple functionality for parsing a JSON response. - """ - # Try to load as json - try: - self.json = ( - request.get_json() - ) #: dict: Dictionary representation of request JSON - # If malformed JSON is passed, make an empty dictionary - except BadRequest as e: - logging.error(e) - self.json = {} - - if self.json is None: - self.json = {} - - # Store raw response data - self.data = request.get_data() #: str: String representation of request data - - if self.data is None: - self.data = "" - - def param(self, key, default=None, convert=None): - """ - Check if a key exists in a JSON/dictionary payload, and returns it. - - Args: - key (str): JSON key to look for - default: Value to return if no matching key-value pair is found. - convert: Converter function. By passing a type, the value will be converted to that type. - **Use with caution!** - """ - # If no JSON payload exists, make an empty dictionary - if not self.json: - self.json = {} - - if key in self.json: - val = self.json[key] - else: - val = default - - if convert and (val is not None): - val = convert(val) - return val - - def gen(camera): """Video streaming generator function.""" while True: @@ -121,12 +73,10 @@ def init_default_extensions(extension_dir): default_ext_path = os.path.join(extension_dir, "defaults.py") if not os.path.isfile(default_ext_path): # If user extensions file doesn't exist - logging.warning( - "No extension file found at {}. Creating...".format(extension_dir) - ) + logging.warning("No extension file found at %s. Creating...", (extension_dir)) create_file(default_ext_path) - logging.info("Populating {}...".format(default_ext_path)) + logging.info("Populating %s...", (default_ext_path)) with open(default_ext_path, "w") as outfile: outfile.write(_DEFAULT_EXTENSION_INIT) diff --git a/openflexure_microscope/api/v2/views/actions/camera.py b/openflexure_microscope/api/v2/views/actions/camera.py index 02d9b229..7c1f1fee 100644 --- a/openflexure_microscope/api/v2/views/actions/camera.py +++ b/openflexure_microscope/api/v2/views/actions/camera.py @@ -1,33 +1,39 @@ import io import logging -from flask import abort, send_file -from labthings import fields, find_component +from flask import send_file +from labthings import Schema, fields, find_component from labthings.views import ActionView from openflexure_microscope.api.v2.views.captures import CaptureSchema +class CaptureResizeSchema(Schema): + width = fields.Integer(example=640, required=True) + height = fields.Integer(example=480, required=True) + + +class BasicCaptureArgs(Schema): + use_video_port = fields.Boolean(missing=False) + bayer = fields.Boolean( + missing=False, description="Include raw bayer data in capture" + ) + resize = fields.Nested(CaptureResizeSchema(), required=False) + + +class FullCaptureArgs(BasicCaptureArgs): + filename = fields.String(example="MyFileName") + temporary = fields.Boolean(missing=False, description="Delete capture on shutdown") + annotations = fields.Dict(missing={}, example={"Client": "SwaggerUI"}) + tags = fields.List(fields.String, missing=[], example=["docs"]) + + class CaptureAPI(ActionView): """ Create a new image capture. """ - args = { - "filename": fields.String(example="MyFileName"), - "temporary": fields.Boolean( - missing=False, description="Delete capture on shutdown" - ), - "use_video_port": fields.Boolean(missing=False), - "bayer": fields.Boolean( - missing=False, description="Store raw bayer data in file" - ), - "annotations": fields.Dict(missing={}, example={"Client": "SwaggerUI"}), - "tags": fields.List(fields.String, missing=[], example=["docs"]), - "resize": fields.Dict( - missing=None, example={"width": 640, "height": 480} - ), # TODO: Validate keys - } + args = FullCaptureArgs() schema = CaptureSchema() def post(self, args): @@ -38,13 +44,10 @@ class CaptureAPI(ActionView): resize = args.get("resize", None) if resize: - if ("width" in resize) and ("height" in resize): - resize = ( - int(resize["width"]), - int(resize["height"]), - ) # Convert dict to tuple - else: - abort(404) + resize = ( + int(resize["width"]), + int(resize["height"]), + ) # Convert dict to tuple # Explicitally acquire lock (prevents empty files being created if lock is unavailable) with microscope.camera.lock: @@ -64,16 +67,7 @@ class RAMCaptureAPI(ActionView): Take a non-persistant image capture. """ - args = { - "use_video_port": fields.Boolean(missing=True), - "bayer": fields.Boolean( - missing=False, description="Return with raw bayer data" - ), - "resize": fields.Dict( - missing=None, example={"width": 640, "height": 480} - ), # TODO: Validate keys - } - + args = BasicCaptureArgs() responses = {200: {"content_type": "image/jpeg"}} def post(self, args): @@ -84,13 +78,10 @@ class RAMCaptureAPI(ActionView): resize = args.get("resize", None) if resize: - if ("width" in resize) and ("height" in resize): - resize = ( - int(resize["width"]), - int(resize["height"]), - ) # Convert dict to tuple - else: - abort(404) + resize = ( + int(resize["width"]), + int(resize["height"]), + ) # Convert dict to tuple # Open a BytesIO stream to be destroyed once request has returned with microscope.camera.lock, io.BytesIO() as stream: @@ -133,8 +124,6 @@ class GPUPreviewStartAPI(ActionView): window = [int(w) for w in window] microscope.camera.start_preview(fullscreen=fullscreen, window=window) - - # TODO: Make schema for microscope state return microscope.state @@ -145,5 +134,4 @@ class GPUPreviewStopAPI(ActionView): """ microscope = find_component("org.openflexure.microscope") microscope.camera.stop_preview() - # TODO: Make schema for microscope state return microscope.state diff --git a/openflexure_microscope/api/v2/views/actions/stage.py b/openflexure_microscope/api/v2/views/actions/stage.py index cd989f5c..53e0b0b1 100644 --- a/openflexure_microscope/api/v2/views/actions/stage.py +++ b/openflexure_microscope/api/v2/views/actions/stage.py @@ -25,11 +25,11 @@ class MoveStageAPI(ActionView): # Handle absolute positioning (calculate a relative move from current position and target) if (args.get("absolute")) and (microscope.stage): # Only if stage exists target_position = axes_to_array(args, ["x", "y", "z"]) - logging.debug("TARGET: {}".format(target_position)) + logging.debug("TARGET: %s", (target_position)) position = [ target_position[i] - microscope.stage.position[i] for i in range(3) ] - logging.debug("DELTA: {}".format(position)) + logging.debug("DELTA: %s", (position)) else: # Get coordinates from payload @@ -45,7 +45,6 @@ class MoveStageAPI(ActionView): else: logging.warning("Unable to move. No stage found.") - # TODO: Make schema for microscope state return microscope.state["stage"]["position"] @@ -60,5 +59,4 @@ class ZeroStageAPI(ActionView): with microscope.stage.lock(timeout=1): microscope.stage.zero_position() - # TODO: Make schema for microscope state return microscope.state["stage"] diff --git a/openflexure_microscope/api/v2/views/captures.py b/openflexure_microscope/api/v2/views/captures.py index 9272aafb..9de19506 100644 --- a/openflexure_microscope/api/v2/views/captures.py +++ b/openflexure_microscope/api/v2/views/captures.py @@ -1,13 +1,13 @@ -import logging - from flask import abort, redirect, request, send_file, url_for from labthings import Schema, fields, find_component -from labthings.marshalling import marshal_with +from labthings.marshalling import marshal_with, use_args from labthings.utilities import description_from_view from labthings.views import PropertyView, View from marshmallow import pre_dump -from openflexure_microscope.api.utilities import JsonResponse, get_bool +from openflexure_microscope.api.utilities import get_bool + +# SCHEMAS class InstrumentSchema(Schema): @@ -23,17 +23,25 @@ class ImageSchema(Schema): format = fields.String() name = fields.String() tags = fields.List(fields.String()) - annotations = fields.Dict() + annotations = fields.Dict(keys=fields.Str(), values=fields.Str()) class CaptureMetadataSchema(Schema): - experimenter = fields.Dict() # TODO: Make schema - experimenterGroup = fields.Dict() # TODO: Make schema - dataset = fields.Dict() # TODO: Make schema + # Full dataset dictionary will change depending on the type of + # dataset, so we can't make a specific schema in this case. + dataset = fields.Dict() + # Nested schema for Image data image = fields.Nested(ImageSchema()) + # Nested schema for instrument data instrument = fields.Nested(InstrumentSchema()) +class BasicDatasetSchema(Schema): + id = fields.UUID() + name = fields.String() + type = fields.String() + + class CaptureSchema(ImageSchema): """ Schema containing only basic attributes required @@ -41,10 +49,15 @@ class CaptureSchema(ImageSchema): are returned by using FullCaptureSchema """ - dataset = fields.Dict() # TODO: Make schema + # We need dataset information in the capture array + # so that client applications can sort data into folders + # without the server having to do a tonne of file IO + dataset = fields.Nested(BasicDatasetSchema()) file = fields.String( data_key="path", description="Path of file on microscope device" ) + # No need to make a schema for links as we only ever + # create the dictionary right here in `generate_links` links = fields.Dict() @pre_dump @@ -104,6 +117,9 @@ class FullCaptureSchema(CaptureSchema): metadata = fields.Nested(CaptureMetadataSchema()) +# VIEWS + + class CaptureList(PropertyView): tags = ["captures"] schema = CaptureSchema(many=True) @@ -203,7 +219,8 @@ class CaptureTags(View): return capture_obj.tags - def put(self, id_): + @use_args(fields.List(fields.String(), required=True)) + def put(self, args, id_): """ Add tags to a single image capture """ @@ -213,17 +230,12 @@ class CaptureTags(View): if not capture_obj: return abort(404) # 404 Not Found - # TODO: Replace with normal Flask request JSON thing - data_dict = JsonResponse(request).json - - if type(data_dict) != list: - return abort(400) - - capture_obj.put_tags(data_dict) + capture_obj.put_tags(args) return capture_obj.tags - def delete(self, id_): + @use_args(fields.List(fields.String(), required=True)) + def delete(self, args, id_): """ Delete tags from a single image capture """ @@ -233,12 +245,7 @@ class CaptureTags(View): if not capture_obj: return abort(404) # 404 Not Found - data_dict = JsonResponse(request).json - - if type(data_dict) != list: - return abort(400) - - for tag in data_dict: + for tag in args: capture_obj.delete_tag(str(tag)) return capture_obj.tags @@ -259,7 +266,8 @@ class CaptureAnnotations(View): return capture_obj.annotations - def put(self, id_): + @use_args(fields.Dict()) + def put(self, args, id_): """ Update metadata for a single image capture """ @@ -269,12 +277,6 @@ class CaptureAnnotations(View): if not capture_obj: return abort(404) # 404 Not Found - data_dict = JsonResponse(request).json - logging.debug(data_dict) - - if type(data_dict) != dict: - return abort(400) - - capture_obj.put_annotations(data_dict) + capture_obj.put_annotations(args) return capture_obj.annotations diff --git a/openflexure_microscope/api/v2/views/instrument.py b/openflexure_microscope/api/v2/views/instrument.py index 000bfb6f..c441240e 100644 --- a/openflexure_microscope/api/v2/views/instrument.py +++ b/openflexure_microscope/api/v2/views/instrument.py @@ -1,12 +1,11 @@ import logging -from flask import abort, request -from labthings import find_component +from flask import abort +from labthings import fields, find_component +from labthings.marshalling import use_args from labthings.utilities import create_from_path, get_by_path, set_by_path from labthings.views import PropertyView, View -from openflexure_microscope.api.utilities import JsonResponse - class SettingsProperty(PropertyView): def get(self): @@ -16,17 +15,17 @@ class SettingsProperty(PropertyView): microscope = find_component("org.openflexure.microscope") return microscope.read_settings() - def put(self): + @use_args(fields.Dict()) + def put(self, args): """ Update current microscope settings, including camera and stage """ microscope = find_component("org.openflexure.microscope") - payload = JsonResponse(request) logging.debug("Updating settings from PUT request:") - logging.debug(payload.json) + logging.debug(args) - microscope.update_settings(payload.json) + microscope.update_settings(args) microscope.save_settings() return self.get() @@ -50,16 +49,16 @@ class NestedSettingsProperty(View): return value - def put(self, route): + @use_args(fields.Dict()) + def put(self, args, route): """ Update a nested section of the current microscope settings """ microscope = find_component("org.openflexure.microscope") keys = route.split("/") - payload = JsonResponse(request) dictionary = create_from_path(keys) - set_by_path(dictionary, keys, payload.json) + set_by_path(dictionary, keys, args) microscope.update_settings(dictionary) microscope.save_settings() diff --git a/openflexure_microscope/camera/base.py b/openflexure_microscope/camera/base.py index de1530a3..dab2b06a 100644 --- a/openflexure_microscope/camera/base.py +++ b/openflexure_microscope/camera/base.py @@ -1,8 +1,8 @@ # -*- coding: utf-8 -*- +import io import logging import threading import time -import io from abc import ABCMeta, abstractmethod from collections import namedtuple @@ -113,10 +113,10 @@ class BaseCamera(metaclass=ABCMeta): def close(self): """Close the BaseCamera and all attached StreamObjects.""" - logging.info("Closing {}".format(self)) + logging.info("Closing %s", (self)) # Stop worker thread self.stop_worker() - logging.info("Closed {}".format(self)) + logging.info("Closed %s", (self)) # START AND STOP WORKER THREAD diff --git a/openflexure_microscope/camera/pi.py b/openflexure_microscope/camera/pi.py index 11dafab8..4d47e2de 100644 --- a/openflexure_microscope/camera/pi.py +++ b/openflexure_microscope/camera/pi.py @@ -156,10 +156,10 @@ class PiCameraStreamer(BaseCamera): for key in PiCameraStreamer.picamera_settings_keys: try: value = getattr(self.camera, key) - logging.debug("Reading PiCamera().{}: {}".format(key, value)) + logging.debug("Reading PiCamera().%s: %s", key, value) conf_dict["picamera"][key] = value except AttributeError: - logging.debug("Unable to read PiCamera attribute {}".format(key)) + logging.debug("Unable to read PiCamera attribute %s", (key)) # Include a serialised lens shading table if ( @@ -243,26 +243,22 @@ class PiCameraStreamer(BaseCamera): # Set exposure mode if "exposure_mode" in settings_dict: logging.debug( - "Applying exposure_mode: {}".format(settings_dict["exposure_mode"]) + "Applying exposure_mode: %s", (settings_dict["exposure_mode"]) ) self.camera.exposure_mode = settings_dict["exposure_mode"] # Apply gains and let them settle if "analog_gain" in settings_dict: - logging.debug( - "Applying analog_gain: {}".format(settings_dict["analog_gain"]) - ) + logging.debug("Applying analog_gain: %s", (settings_dict["analog_gain"])) set_analog_gain(self.camera, float(settings_dict["analog_gain"])) if "digital_gain" in settings_dict: - logging.debug( - "Applying digital_gain: {}".format(settings_dict["digital_gain"]) - ) + logging.debug("Applying digital_gain: %s", (settings_dict["digital_gain"])) set_digital_gain(self.camera, float(settings_dict["digital_gain"])) # Apply shutter speed if "shutter_speed" in settings_dict: logging.debug( - "Applying shutter_speed: {}".format(settings_dict["shutter_speed"]) + "Applying shutter_speed: %s", (settings_dict["shutter_speed"]) ) self.camera.shutter_speed = int(settings_dict["shutter_speed"]) @@ -272,17 +268,17 @@ class PiCameraStreamer(BaseCamera): if "awb_gains" in settings_dict: logging.debug("Applying awb_mode: off") self.camera.awb_mode = "off" - logging.debug("Applying awb_gains: {}".format(settings_dict["awb_gains"])) + logging.debug("Applying awb_gains: %s", (settings_dict["awb_gains"])) self.camera.awb_gains = settings_dict["awb_gains"] elif "awb_mode" in settings_dict: - logging.debug("Applying awb_mode: {}".format(settings_dict["awb_mode"])) + logging.debug("Applying awb_mode: %s", (settings_dict["awb_mode"])) self.camera.awb_mode = settings_dict["awb_mode"] # Handle some properties that can be quickly applied batched_keys = ["framerate", "saturation"] for key in batched_keys: if (key in settings_dict) and hasattr(self.camera, key): - logging.debug("Applying {}: {}".format(key, settings_dict[key])) + logging.debug("Applying %s: %s", key, settings_dict[key]) setattr(self.camera, key, settings_dict[key]) # Final optional pause to settle @@ -307,7 +303,7 @@ class PiCameraStreamer(BaseCamera): for i in range(2): if np.abs(centre[i] - 0.5) + size / 2 > 0.5: centre[i] = 0.5 + (1.0 - size) / 2 * np.sign(centre[i] - 0.5) - logging.info("setting zoom, centre {}, size {}".format(centre, size)) + logging.info("setting zoom, centre %s, size %s", centre, size) new_fov = (centre[0] - size / 2, centre[1] - size / 2, size, size) self.camera.zoom = new_fov @@ -329,13 +325,12 @@ class PiCameraStreamer(BaseCamera): self.preview_active = True except picamerax.exc.PiCameraMMALError as e: logging.error( - "Suppressed a MMALError in start_preview. Exception: {}".format(e) + "Suppressed a MMALError in start_preview. Exception: %s", (e) ) except picamerax.exc.PiCameraValueError as e: logging.error( - "Suppressed a ValueError exception in start_preview. Exception: {}".format( - e - ) + "Suppressed a ValueError exception in start_preview. Exception: %s", + (e), ) def stop_preview(self): @@ -364,7 +359,7 @@ class PiCameraStreamer(BaseCamera): if not self.record_active: # Start the camera video recording on port 2 - logging.info("Recording to {}".format(output)) + logging.info("Recording to %s", (output)) self.camera.start_recording( output, @@ -413,12 +408,12 @@ class PiCameraStreamer(BaseCamera): try: self.camera.stop_recording(splitter_port=splitter_port) except picamerax.exc.PiCameraNotRecording: - logging.info("Not recording on splitter_port {}".format(splitter_port)) + logging.info("Not recording on splitter_port %s", (splitter_port)) else: logging.info( - "Stopped MJPEG stream on port {1}. Switching to {0}.".format( - self.image_resolution, splitter_port - ) + "Stopped MJPEG stream on port %s. Switching to %s.", + splitter_port, + self.image_resolution, ) # Increase the resolution for taking an image @@ -470,9 +465,9 @@ class PiCameraStreamer(BaseCamera): ) else: logging.debug( - "Started MJPEG stream at {} on port {}".format( - self.stream_resolution, splitter_port - ) + "Started MJPEG stream at %s on port %s", + self.stream_resolution, + splitter_port, ) def capture( @@ -501,7 +496,7 @@ class PiCameraStreamer(BaseCamera): output_object (str/BytesIO): Target object. """ with self.lock: - logging.info("Capturing to {}".format(output)) + logging.info("Capturing to %s", (output)) # Set resolution and stop stream recording if necessary if not use_video_port: @@ -537,7 +532,7 @@ class PiCameraStreamer(BaseCamera): logging.debug("Creating PiRGBArray") with picamerax.array.PiRGBArray(self.camera) as output: - logging.info("Capturing to {}".format(output)) + logging.info("Capturing to %s", (output)) self.camera.capture(output, format="rgb", use_video_port=True) diff --git a/openflexure_microscope/camera/set_picamera_gain.py b/openflexure_microscope/camera/set_picamera_gain.py index d1d7175a..fc04f851 100644 --- a/openflexure_microscope/camera/set_picamera_gain.py +++ b/openflexure_microscope/camera/set_picamera_gain.py @@ -55,26 +55,17 @@ if __name__ == "__main__": # fix the shutter speed cam.shutter_speed = cam.exposure_speed - logging.info( - "Current a/d gains: {}, {}".format(cam.analog_gain, cam.digital_gain) - ) + logging.info("Current a/d gains: %s, %s", cam.analog_gain, cam.digital_gain) logging.info("Attempting to set analogue gain to 1") set_analog_gain(cam, 1) logging.info("Attempting to set digital gain to 1") set_digital_gain(cam, 1) - # The old code is left in here in case it is a useful example... - # ret = mmal.mmal_port_parameter_set_rational(cam._camera.control._port, - # MMAL_PARAMETER_DIGITAL_GAIN, - # to_rational(1)) - # print("Return code: {}".format(ret)) try: while True: logging.info( - "Current a/d gains: {}, {}".format( - cam.analog_gain, cam.digital_gain - ) + "Current a/d gains: %s, %s", cam.analog_gain, cam.digital_gain ) time.sleep(1) except KeyboardInterrupt: diff --git a/openflexure_microscope/captures/__init__.py b/openflexure_microscope/captures/__init__.py index 4df0ac64..7d15ca40 100644 --- a/openflexure_microscope/captures/__init__.py +++ b/openflexure_microscope/captures/__init__.py @@ -1,3 +1,3 @@ from . import capture, capture_manager -from .capture import CaptureObject, THUMBNAIL_SIZE +from .capture import THUMBNAIL_SIZE, CaptureObject from .capture_manager import CaptureManager diff --git a/openflexure_microscope/captures/capture.py b/openflexure_microscope/captures/capture.py index fcddc98f..36d252b4 100644 --- a/openflexure_microscope/captures/capture.py +++ b/openflexure_microscope/captures/capture.py @@ -6,13 +6,13 @@ import logging import os import uuid from collections import OrderedDict -from PIL import Image import dateutil.parser +from PIL import Image -from openflexure_microscope.camera import piexif -from openflexure_microscope.camera.piexif._exceptions import InvalidImageDataError -from openflexure_microscope.config import JSONEncoder +from openflexure_microscope.captures import piexif +from openflexure_microscope.captures.piexif import InvalidImageDataError +from openflexure_microscope.json import JSONEncoder EXIF_FORMATS = ["JPG", "JPEG", "TIF", "TIFF"] THUMBNAIL_SIZE = (200, 150) @@ -25,23 +25,23 @@ def make_file_list(directory, formats): glob.glob("{}/**/*.{}".format(directory, fmt.lower()), recursive=True) ) - logging.info("{} capture files found on disk".format(len(files))) + logging.info("%s capture files found on disk", (len(files))) return files def build_captures_from_exif(capture_path): - logging.debug("Reloading captures from {}...".format(capture_path)) + logging.debug("Reloading captures from %s...", (capture_path)) files = make_file_list(capture_path, EXIF_FORMATS) captures = OrderedDict() for f in files: - logging.debug("Reloading capture {}...".format(f)) + logging.debug("Reloading capture %s...", (f)) capture = capture_from_path(f) if capture: captures[capture.id] = capture - logging.info("{} capture files successfully reloaded".format(len(captures))) + logging.info("%s capture files successfully reloaded", (len(captures))) return captures @@ -58,7 +58,7 @@ def capture_from_path(path): capture.sync_basic_metadata() return capture except (InvalidImageDataError, json.decoder.JSONDecodeError): - logging.error("Invalid metadata at {}.".format(path)) + logging.error("Invalid metadata at %s.", (path)) return None @@ -75,7 +75,7 @@ class CaptureObject(object): # Store a nice ID self.id = uuid.uuid4() #: str: Unique capture ID - logging.debug("Created CaptureObject {}".format(self.id)) + logging.debug("Created CaptureObject %s", (self.id)) self.time = datetime.datetime.now() @@ -316,7 +316,7 @@ class CaptureObject(object): """ if self.exists: # If data file exists - logging.info("Opening from file {}".format(self.file)) + logging.info("Opening from file %s", (self.file)) with open(self.file, "rb") as f: d = io.BytesIO(f.read()) # Load bytes from file d.seek(0) # Rewind loaded bytestream @@ -364,7 +364,7 @@ class CaptureObject(object): """If the StreamObject has been saved, delete the file.""" if os.path.isfile(self.file): - logging.info("Deleting file {}".format(self.file)) + logging.info("Deleting file %s", (self.file)) os.remove(self.file) return True diff --git a/openflexure_microscope/captures/capture_manager.py b/openflexure_microscope/captures/capture_manager.py index e5e290eb..d636f7a4 100644 --- a/openflexure_microscope/captures/capture_manager.py +++ b/openflexure_microscope/captures/capture_manager.py @@ -61,7 +61,7 @@ class CaptureManager: self.close() def close(self): - logging.info("Closing {}".format(self)) + logging.info("Closing %s", (self)) # Close all StreamObjects for capture_list in [self.images.values(), self.videos.values()]: for stream_object in capture_list: @@ -75,9 +75,9 @@ class CaptureManager: """ if os.path.isdir(self.paths["temp"]): - logging.info("Clearing {}...".format(self.paths["temp"])) + logging.info("Clearing %s...", (self.paths["temp"])) shutil.rmtree(self.paths["temp"]) - logging.debug("Cleared {}.".format(self.paths["temp"])) + logging.debug("Cleared %s.", (self.paths["temp"])) def rebuild_captures(self): self.images = build_captures_from_exif(self.paths["default"]) @@ -118,25 +118,7 @@ class CaptureManager: # CREATING NEW CAPTURES - def new_image( - self, - temporary: bool = True, - filename: str = None, - folder: str = "", - fmt: str = "jpeg", - ): - - """ - Create a new image capture object. - - Args: - temporary (bool): Should the data be deleted after session ends. - Creating the capture with a content manager sets this to true. - filename (str): Name of the stored file. Defaults to timestamp. - folder (str): Name of the folder in which to store the capture. - fmt (str): Format of the capture. - """ - + def _new_output(self, temporary, filename, folder, fmt): # Generate file name if not filename: filename = generate_numbered_basename(self.images.values()) @@ -156,10 +138,32 @@ class CaptureManager: if temporary: output.put_tags(["temporary"]) + return output + + def new_image( + self, + temporary: bool = True, + filename: str = None, + folder: str = "", + fmt: str = "jpeg", + ): + + """ + Create a new image capture object. + + Args: + temporary (bool): Should the data be deleted after session ends. + Creating the capture with a content manager sets this to true. + filename (str): Name of the stored file. Defaults to timestamp. + folder (str): Name of the folder in which to store the capture. + fmt (str): Format of the capture. + """ + # Create a new output object + output = self._new_output(temporary, filename, folder, fmt) + # Update capture list capture_key = str(output.id) logging.debug("Adding image %s with key %s", output, capture_key) - self.images[capture_key] = output return output @@ -182,26 +186,8 @@ class CaptureManager: folder (str): Name of the folder in which to store the capture. fmt (str): Format of the capture. """ - # TODO: Remove the redundancy here - - # Generate file name - if not filename: - filename = generate_numbered_basename(self.videos.values()) - logging.debug(filename) - filename = "{}.{}".format(filename, fmt) - - # Generate folder - base_folder = self.paths["temp"] if temporary else self.paths["default"] - folder = os.path.join(base_folder, folder) - - # Generate file path - filepath = os.path.join(folder, filename) - - # Create capture object - output = CaptureObject(filepath=filepath) - # Insert a temporary tag if temporary - if temporary: - output.put_tags(["temporary"]) + # Create a new output object + output = self._new_output(temporary, filename, folder, fmt) # Update capture list capture_key = str(output.id) diff --git a/openflexure_microscope/camera/piexif/LICENSE.txt b/openflexure_microscope/captures/piexif/LICENSE.txt similarity index 100% rename from openflexure_microscope/camera/piexif/LICENSE.txt rename to openflexure_microscope/captures/piexif/LICENSE.txt diff --git a/openflexure_microscope/camera/piexif/__init__.py b/openflexure_microscope/captures/piexif/__init__.py similarity index 100% rename from openflexure_microscope/camera/piexif/__init__.py rename to openflexure_microscope/captures/piexif/__init__.py diff --git a/openflexure_microscope/camera/piexif/_common.py b/openflexure_microscope/captures/piexif/_common.py similarity index 100% rename from openflexure_microscope/camera/piexif/_common.py rename to openflexure_microscope/captures/piexif/_common.py diff --git a/openflexure_microscope/camera/piexif/_dump.py b/openflexure_microscope/captures/piexif/_dump.py similarity index 100% rename from openflexure_microscope/camera/piexif/_dump.py rename to openflexure_microscope/captures/piexif/_dump.py diff --git a/openflexure_microscope/camera/piexif/_exceptions.py b/openflexure_microscope/captures/piexif/_exceptions.py similarity index 100% rename from openflexure_microscope/camera/piexif/_exceptions.py rename to openflexure_microscope/captures/piexif/_exceptions.py diff --git a/openflexure_microscope/camera/piexif/_exif.py b/openflexure_microscope/captures/piexif/_exif.py similarity index 100% rename from openflexure_microscope/camera/piexif/_exif.py rename to openflexure_microscope/captures/piexif/_exif.py diff --git a/openflexure_microscope/camera/piexif/_insert.py b/openflexure_microscope/captures/piexif/_insert.py similarity index 100% rename from openflexure_microscope/camera/piexif/_insert.py rename to openflexure_microscope/captures/piexif/_insert.py diff --git a/openflexure_microscope/camera/piexif/_load.py b/openflexure_microscope/captures/piexif/_load.py similarity index 100% rename from openflexure_microscope/camera/piexif/_load.py rename to openflexure_microscope/captures/piexif/_load.py diff --git a/openflexure_microscope/camera/piexif/_remove.py b/openflexure_microscope/captures/piexif/_remove.py similarity index 100% rename from openflexure_microscope/camera/piexif/_remove.py rename to openflexure_microscope/captures/piexif/_remove.py diff --git a/openflexure_microscope/camera/piexif/_transplant.py b/openflexure_microscope/captures/piexif/_transplant.py similarity index 100% rename from openflexure_microscope/camera/piexif/_transplant.py rename to openflexure_microscope/captures/piexif/_transplant.py diff --git a/openflexure_microscope/camera/piexif/_webp.py b/openflexure_microscope/captures/piexif/_webp.py similarity index 100% rename from openflexure_microscope/camera/piexif/_webp.py rename to openflexure_microscope/captures/piexif/_webp.py diff --git a/openflexure_microscope/camera/piexif/helper.py b/openflexure_microscope/captures/piexif/helper.py similarity index 100% rename from openflexure_microscope/camera/piexif/helper.py rename to openflexure_microscope/captures/piexif/helper.py diff --git a/openflexure_microscope/config.py b/openflexure_microscope/config.py index cfca29f3..5788a385 100644 --- a/openflexure_microscope/config.py +++ b/openflexure_microscope/config.py @@ -3,18 +3,9 @@ import json import logging import os import shutil -from fractions import Fraction -from uuid import UUID -import numpy as np -from labthings.json import LabThingsJSONEncoder - -from .paths import ( - CONFIGURATION_FILE_PATH, - DEFAULT_CONFIGURATION_FILE_PATH, - DEFAULT_SETTINGS_FILE_PATH, - SETTINGS_FILE_PATH, -) +from .json import JSONEncoder +from .paths import CONFIGURATION_FILE_PATH, SETTINGS_FILE_PATH class OpenflexureSettingsFile: @@ -33,7 +24,12 @@ class OpenflexureSettingsFile: self.path = path # Initialise basic config file with defaults if it doesn't exist - initialise_file(self.path, populate=defaults) + initialise_file( + self.path, + # Populate with default dictionary, or empty JSON if empty + populate=json.dumps(defaults, cls=JSONEncoder, indent=2, sort_keys=True) + or "{}\n", + ) def load(self) -> dict: """ @@ -78,32 +74,6 @@ class OpenflexureSettingsFile: return settings -class JSONEncoder(LabThingsJSONEncoder): - """ - A custom JSON encoder, with type conversions for PiCamera fractions, Numpy integers, and Numpy arrays - """ - - def default(self, o): - if isinstance(o, UUID): - return str(o) - # PiCamera fractions - elif isinstance(o, Fraction): - return float(o) - # Numpy integers - elif isinstance(o, np.integer): - return int(o) - # Numpy arrays - elif isinstance(o, np.ndarray): - return o.tolist() - # UUIDs - elif isinstance(o, UUID): - return str(o) - else: - # call base class implementation which takes care of - # raising exceptions for unsupported types - return LabThingsJSONEncoder.default(self, o) - - # HANDLE BASIC LOADING AND SAVING OF SETTINGS FILES @@ -116,7 +86,7 @@ def load_json_file(config_path) -> dict: """ config_path = os.path.expanduser(config_path) - logging.info("Loading {}...".format(config_path)) + logging.info("Loading %s...", config_path) with open(config_path) as config_file: try: @@ -139,7 +109,7 @@ def save_json_file(config_path: str, config_dict: dict): """ config_path = os.path.expanduser(config_path) - logging.info("Saving {}...".format(config_path)) + logging.info("Saving %s...", config_path) logging.debug(config_dict) with open(config_path, "w") as outfile: @@ -172,33 +142,26 @@ def initialise_file(config_path, populate: str = "{}\n"): """ config_path = os.path.expanduser(config_path) - logging.debug("Initialising {}".format(config_path)) - logging.debug("Exists: {}".format(os.path.exists(config_path))) + logging.debug("Initialising %s", (config_path)) + logging.debug("Exists: %s", (os.path.exists(config_path))) if not os.path.exists(config_path): # If user config file doesn't exist - logging.warning("No config file found at {}. Creating...".format(config_path)) + logging.warning("No config file found at %s. Creating...", (config_path)) create_file(config_path) - logging.info("Populating {}...".format(config_path)) + logging.info("Populating %s...", (config_path)) with open(config_path, "w") as outfile: outfile.write(populate) -# Load the default settings -with open(DEFAULT_SETTINGS_FILE_PATH, "r") as default_settings: - DEFAULT_SETTINGS = default_settings.read() - #: Default user settings object -user_settings = OpenflexureSettingsFile( - path=SETTINGS_FILE_PATH, defaults=DEFAULT_SETTINGS -) - - -# Load the default configuration -with open(DEFAULT_CONFIGURATION_FILE_PATH, "r") as default_configuration: - DEFAULT_CONFIGURATION = default_configuration.read() +user_settings = OpenflexureSettingsFile(path=SETTINGS_FILE_PATH) #: Default user settings object user_configuration = OpenflexureSettingsFile( - path=CONFIGURATION_FILE_PATH, defaults=DEFAULT_CONFIGURATION + path=CONFIGURATION_FILE_PATH, + defaults={ + "camera": {"type": "PiCamera"}, + "stage": {"type": "SangaStage", "port": None}, + }, ) diff --git a/openflexure_microscope/devel/__init__.py b/openflexure_microscope/devel/__init__.py index a8e5d39a..fe8d387b 100644 --- a/openflexure_microscope/devel/__init__.py +++ b/openflexure_microscope/devel/__init__.py @@ -13,8 +13,6 @@ from labthings import current_action as current_task from labthings import update_action_data as update_task_data from labthings import update_action_progress as update_task_progress -from openflexure_microscope.api.utilities import JsonResponse - __all__ = [ "current_task", "update_task_progress", diff --git a/openflexure_microscope/json.py b/openflexure_microscope/json.py new file mode 100644 index 00000000..37b0d7d8 --- /dev/null +++ b/openflexure_microscope/json.py @@ -0,0 +1,31 @@ +from fractions import Fraction +from uuid import UUID + +import numpy as np +from labthings.json import LabThingsJSONEncoder + + +class JSONEncoder(LabThingsJSONEncoder): + """ + A custom JSON encoder, with type conversions for PiCamera fractions, Numpy integers, and Numpy arrays + """ + + def default(self, o): + if isinstance(o, UUID): + return str(o) + # PiCamera fractions + elif isinstance(o, Fraction): + return float(o) + # Numpy integers + elif isinstance(o, np.integer): + return int(o) + # Numpy arrays + elif isinstance(o, np.ndarray): + return o.tolist() + # UUIDs + elif isinstance(o, UUID): + return str(o) + else: + # call base class implementation which takes care of + # raising exceptions for unsupported types + return LabThingsJSONEncoder.default(self, o) diff --git a/openflexure_microscope/microscope.py b/openflexure_microscope/microscope.py index 17d2be9f..b8b543d0 100644 --- a/openflexure_microscope/microscope.py +++ b/openflexure_microscope/microscope.py @@ -10,7 +10,7 @@ import pkg_resources from expiringdict import ExpiringDict from openflexure_microscope.camera.mock import MissingCamera -from openflexure_microscope.captures import CaptureManager, THUMBNAIL_SIZE +from openflexure_microscope.captures import THUMBNAIL_SIZE, CaptureManager from openflexure_microscope.stage.mock import MissingStage from openflexure_microscope.stage.sanga import SangaDeltaStage, SangaStage @@ -71,7 +71,7 @@ class Microscope: def close(self): """Shut down the microscope hardware.""" - logging.info("Closing {}".format(self)) + logging.info("Closing %s", (self)) if self.camera: try: self.camera.close() @@ -83,7 +83,7 @@ class Microscope: except TimeoutError as e: logging.error(e) self.captures.close() - logging.info("Closed {}".format(self)) + logging.info("Closed %s", (self)) def setup(self, configuration): """ @@ -196,32 +196,34 @@ class Microscope: Applies a settings dictionary to the microscope. Missing parameters will be left untouched. """ with self.lock: - logging.debug("Microscope: Applying settings: {}".format(settings)) + logging.debug("Microscope: Applying settings: %s", (settings)) # If attached to a camera if ("camera" in settings) and self.camera: - self.camera.update_settings(settings.get("camera", {})) + self.camera.update_settings(settings.pop("camera", {})) # If attached to a stage if ("stage" in settings) and self.stage: - self.stage.update_settings(settings.get("stage", {})) + self.stage.update_settings(settings.pop("stage", {})) # Capture manager - self.captures.update_settings(settings.get("captures", {})) + self.captures.update_settings(settings.pop("captures", {})) # Microscope settings if "id" in settings: - self.id = settings["id"] + self.id = settings.pop("id") if "name" in settings: - self.name = settings["name"] + self.name = settings.pop("name") if "fov" in settings: - self.fov = settings["fov"] + self.fov = settings.pop("fov") # Extension settings if "extensions" in settings: - self.extension_settings.update(settings["extensions"]) + self.extension_settings.update(settings.pop("extensions")) - # TODO: warn if there are settings that we silently ignore + # Warn about any superfluous keys + for key in settings.keys(): + logging.warning("Key %s is unused and was ignored", key) def read_settings(self, full: bool = True): """ diff --git a/openflexure_microscope/microscope_configuration.default.json b/openflexure_microscope/microscope_configuration.default.json deleted file mode 100644 index b9cbfdce..00000000 --- a/openflexure_microscope/microscope_configuration.default.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "camera": { - "type": "PiCamera" - }, - "stage": { - "type": "SangaStage", - "port": null - } -} \ No newline at end of file diff --git a/openflexure_microscope/microscope_settings.default.json b/openflexure_microscope/microscope_settings.default.json deleted file mode 100644 index 5c408d2e..00000000 --- a/openflexure_microscope/microscope_settings.default.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "fov": [ - 4100, - 3146 - ], - "jpeg_quality": 100 -} \ No newline at end of file diff --git a/openflexure_microscope/paths.py b/openflexure_microscope/paths.py index d497873b..38998580 100644 --- a/openflexure_microscope/paths.py +++ b/openflexure_microscope/paths.py @@ -39,17 +39,6 @@ def logs_file_path(filename: str): return os.path.join(logs_dir, filename) -# HANDLE DEFAULTS FILES STORED IN THIS APPLICATION - -HERE = os.path.abspath(os.path.dirname(__file__)) - -#: Path of default (first-run) microscope settings -DEFAULT_SETTINGS_FILE_PATH = os.path.join(HERE, "microscope_settings.default.json") -#: Path of default (first-run) microscope configuration -DEFAULT_CONFIGURATION_FILE_PATH = os.path.join( - HERE, "microscope_configuration.default.json" -) - # BASE PATHS if os.name == "nt": diff --git a/openflexure_microscope/rescue/auto.py b/openflexure_microscope/rescue/auto.py index 5bb95f22..9c195113 100644 --- a/openflexure_microscope/rescue/auto.py +++ b/openflexure_microscope/rescue/auto.py @@ -1,10 +1,9 @@ import logging import os -import sys import platform -import pkg_resources +import sys -from .error_sources import bcolors +import pkg_resources from openflexure_microscope.paths import ( FALLBACK_OPENFLEXURE_VAR_PATH, @@ -13,11 +12,12 @@ from openflexure_microscope.paths import ( from . import ( check_capture_reload, - check_settings, check_picamera, check_sangaboard, + check_settings, check_system, ) +from .error_sources import bcolors # Paths for suggestions LOGS_PATHS = [ @@ -46,9 +46,7 @@ else: logger.setLevel(logging.WARNING) -if __name__ == "__main__": - spoof = False - +def main(): print() print(bcolors.HEADER + "OpenFlexure Rescue" + bcolors.ENDC) print() @@ -88,3 +86,7 @@ if __name__ == "__main__": print() for err in error_sources: print(err.message) + + +if __name__ == "__main__": + main() diff --git a/openflexure_microscope/rescue/check_capture_reload.py b/openflexure_microscope/rescue/check_capture_reload.py index 54459665..e50ae05b 100644 --- a/openflexure_microscope/rescue/check_capture_reload.py +++ b/openflexure_microscope/rescue/check_capture_reload.py @@ -1,9 +1,9 @@ import logging from openflexure_microscope.captures.capture import ( + EXIF_FORMATS, build_captures_from_exif, make_file_list, - EXIF_FORMATS, ) from openflexure_microscope.captures.capture_manager import BASE_CAPTURE_PATH from openflexure_microscope.config import user_settings diff --git a/openflexure_microscope/rescue/check_sangaboard.py b/openflexure_microscope/rescue/check_sangaboard.py index 8c7f6aa7..13b154b1 100644 --- a/openflexure_microscope/rescue/check_sangaboard.py +++ b/openflexure_microscope/rescue/check_sangaboard.py @@ -1,7 +1,7 @@ -from .error_sources import ErrorSource - from openflexure_microscope.config import user_configuration +from .error_sources import ErrorSource + def main(): error_sources = [] diff --git a/openflexure_microscope/rescue/check_settings.py b/openflexure_microscope/rescue/check_settings.py index 1c28c9fd..64fffb92 100644 --- a/openflexure_microscope/rescue/check_settings.py +++ b/openflexure_microscope/rescue/check_settings.py @@ -1,34 +1,49 @@ import json import logging +from .error_sources import ErrorSource + ERROR_SOURCES = [] def trace_config_exceptions(): error_sources = [] - from openflexure_microscope.paths import ( - DEFAULT_CONFIGURATION_FILE_PATH, - SETTINGS_FILE_PATH, - ) + from openflexure_microscope.paths import CONFIGURATION_FILE_PATH, SETTINGS_FILE_PATH try: - default_config = json.load(DEFAULT_CONFIGURATION_FILE_PATH) + default_config = json.load(CONFIGURATION_FILE_PATH) if not default_config: - error_sources.append("default_config_empty") + error_sources.append( + ErrorSource( + "Configuration file is missing or empty. This may occur if the server has never been started." + ) + ) except Exception as e: # pylint: disable=W0703 logging.error("Error parsing config:") logging.error(e) - error_sources.append("default_config_error") + error_sources.append( + ErrorSource( + f"Configuration file is malformed. You can reset to the default configuration by deleting {CONFIGURATION_FILE_PATH}." + ) + ) try: default_settings = json.load(SETTINGS_FILE_PATH) if not default_settings: - error_sources.append("default_settings_empty") + error_sources.append( + ErrorSource( + "Settings file is missing or empty. This may occur if the server has never been started." + ) + ) except Exception as e: # pylint: disable=W0703 logging.error("Error parsing settings:") logging.error(e) - error_sources.append("default_settings_error") + error_sources.append( + ErrorSource( + f"Settings file is malformed. You can reset to the default settings by deleting {SETTINGS_FILE_PATH}." + ) + ) return error_sources @@ -39,7 +54,11 @@ def main(): try: from openflexure_microscope import config as _ except Exception as e: # pylint: disable=W0703 - error_sources.append("config_settings_import_error") + error_sources.append( + ErrorSource( + "Error importing configuration submodule. This could be an error in our code." + ) + ) logging.error("Error importing config:") logging.error(e) error_sources.extend(trace_config_exceptions()) diff --git a/openflexure_microscope/rescue/check_system.py b/openflexure_microscope/rescue/check_system.py index 1c3f31ee..880ba87c 100644 --- a/openflexure_microscope/rescue/check_system.py +++ b/openflexure_microscope/rescue/check_system.py @@ -1,5 +1,6 @@ -from urllib.request import urlopen from urllib.error import URLError +from urllib.request import urlopen + import psutil from .error_sources import WarningSource diff --git a/openflexure_microscope/stage/sanga.py b/openflexure_microscope/stage/sanga.py index 549ab5b9..c3a6c939 100644 --- a/openflexure_microscope/stage/sanga.py +++ b/openflexure_microscope/stage/sanga.py @@ -81,7 +81,7 @@ class SangaStage(BaseStage): @backlash.setter def backlash(self, blsh): - logging.debug("Setting backlash to {}".format(blsh)) + logging.debug("Setting backlash to %s", (blsh)) if blsh is None: self._backlash = None elif isinstance(blsh, Iterable): @@ -262,7 +262,7 @@ class SangaDeltaStage(SangaStage): # Transform into delta coordinates displacement = np.dot(self.Tdv, displacement) - logging.debug("Delta displacement: {}".format(displacement)) + logging.debug("Delta displacement: %s", (displacement)) # Do the move SangaStage.move_rel(self, displacement, axis=None, backlash=backlash) @@ -274,7 +274,7 @@ class SangaDeltaStage(SangaStage): # Transform into delta coordinates final = np.dot(self.Tdv, final) - logging.debug("Delta final: {}".format(final)) + logging.debug("Delta final: %s", (final)) # Do the move SangaStage.move_abs(self, final, **kwargs) diff --git a/poetry.lock b/poetry.lock index d3b58da7..13771f48 100644 --- a/poetry.lock +++ b/poetry.lock @@ -79,7 +79,7 @@ tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (> [[package]] name = "babel" -version = "2.8.1" +version = "2.9.0" description = "Internationalization utilities" category = "dev" optional = false @@ -283,7 +283,7 @@ i18n = ["Babel (>=0.8)"] [[package]] name = "labthings" -version = "1.1.2" +version = "1.1.3" description = "Python implementation of LabThings, based on the Flask microframework" category = "main" optional = false @@ -483,7 +483,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" [[package]] name = "requests" -version = "2.24.0" +version = "2.25.0" description = "Python HTTP for Humans." category = "dev" optional = false @@ -493,7 +493,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" certifi = ">=2017.4.17" chardet = ">=3.0.2,<4" idna = ">=2.5,<3" -urllib3 = ">=1.21.1,<1.25.0 || >1.25.0,<1.25.1 || >1.25.1,<1.26" +urllib3 = ">=1.21.1,<1.27" [package.extras] security = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)"] @@ -557,7 +557,7 @@ python-versions = "*" [[package]] name = "sphinx" -version = "3.3.0" +version = "3.3.1" description = "Python documentation generator" category = "dev" optional = false @@ -687,7 +687,7 @@ python-versions = "*" [[package]] name = "urllib3" -version = "1.25.11" +version = "1.26.1" description = "HTTP library with thread-safe connection pooling, file post, and more." category = "dev" optional = false @@ -752,8 +752,8 @@ rpi = ["RPi.GPIO"] [metadata] lock-version = "1.1" -python-versions = "^3.6" -content-hash = "fc03c76cedfd54d4d6be2ced9eb236dfdd1ffb986fdc731f96ee560053f7aa8a" +python-versions = "^3.6.1" +content-hash = "e0aad912d5cb388d3ff395dc8a517f701ba2cbe4c2169771080a889906e91b41" [metadata.files] alabaster = [ @@ -781,8 +781,8 @@ attrs = [ {file = "attrs-20.3.0.tar.gz", hash = "sha256:832aa3cde19744e49938b91fea06d69ecb9e649c93ba974535d08ad92164f700"}, ] babel = [ - {file = "Babel-2.8.1-py2.py3-none-any.whl", hash = "sha256:be432f50d6c38c705ea45a0c05a4bbb22a70742a93888fbae5e7948da1635d23"}, - {file = "Babel-2.8.1.tar.gz", hash = "sha256:820c195271534e8a86f564ba9ef2c207f356cfeb7e94d2bdc6b57897c4233837"}, + {file = "Babel-2.9.0-py2.py3-none-any.whl", hash = "sha256:9d35c22fcc79893c3ecc85ac4a56cde1ecf3f19c540bba0922308a6c06ca6fa5"}, + {file = "Babel-2.9.0.tar.gz", hash = "sha256:da031ab54472314f210b0adcff1588ee5d1d1d0ba4dbd07b94dba82bde791e05"}, ] black = [ {file = "black-18.9b0-py36-none-any.whl", hash = "sha256:817243426042db1d36617910df579a54f1afd659adb96fc5032fcf4b36209739"}, @@ -855,8 +855,8 @@ jinja2 = [ {file = "Jinja2-2.11.2.tar.gz", hash = "sha256:89aab215427ef59c34ad58735269eb58b1a5808103067f7bb9d5836c651b3bb0"}, ] labthings = [ - {file = "labthings-1.1.2-py3-none-any.whl", hash = "sha256:1e17e20c3585dbb2293ca1e75c46c4b9a21ee3aa30b71a2d839e16f40c21f600"}, - {file = "labthings-1.1.2.tar.gz", hash = "sha256:0d456f11920c4cd84e94afae2e7f005c3ea65346da5523d51aeec17698808156"}, + {file = "labthings-1.1.3-py3-none-any.whl", hash = "sha256:51f0bc897fcbacbcbb911658d3daafd41a35373bca4ef2156423cdb728fa3d78"}, + {file = "labthings-1.1.3.tar.gz", hash = "sha256:3d68c638866f1c525a8b5285570c2d4599005d227b928e78088c0a29e6544007"}, ] lazy-object-proxy = [ {file = "lazy-object-proxy-1.4.3.tar.gz", hash = "sha256:f3900e8a5de27447acbf900b4750b0ddfd7ec1ea7fbaf11dfa911141bc522af0"}, @@ -1058,8 +1058,8 @@ pyyaml = [ {file = "PyYAML-5.3.1.tar.gz", hash = "sha256:b8eac752c5e14d3eca0e6dd9199cd627518cb5ec06add0de9d32baeee6fe645d"}, ] requests = [ - {file = "requests-2.24.0-py2.py3-none-any.whl", hash = "sha256:fe75cc94a9443b9246fc7049224f75604b113c36acb93f87b80ed42c44cbb898"}, - {file = "requests-2.24.0.tar.gz", hash = "sha256:b3559a131db72c33ee969480840fff4bb6dd111de7dd27c8ee1f820f4f00231b"}, + {file = "requests-2.25.0-py2.py3-none-any.whl", hash = "sha256:e786fa28d8c9154e6a4de5d46a1d921b8749f8b74e28bde23768e5e16eece998"}, + {file = "requests-2.25.0.tar.gz", hash = "sha256:7f1a0b932f4a60a1a65caa4263921bb7d9ee911957e0ae4a23a6dd08185ad5f8"}, ] rope = [ {file = "rope-0.14.0-py2-none-any.whl", hash = "sha256:6b728fdc3e98a83446c27a91fc5d56808a004f8beab7a31ab1d7224cecc7d969"}, @@ -1103,8 +1103,8 @@ snowballstemmer = [ {file = "snowballstemmer-2.0.0.tar.gz", hash = "sha256:df3bac3df4c2c01363f3dd2cfa78cce2840a79b9f1c2d2de9ce8d31683992f52"}, ] sphinx = [ - {file = "Sphinx-3.3.0-py3-none-any.whl", hash = "sha256:3abdb2c57a65afaaa4f8573cbabd5465078eb6fd282c1e4f87f006875a7ec0c7"}, - {file = "Sphinx-3.3.0.tar.gz", hash = "sha256:1c21e7c5481a31b531e6cbf59c3292852ccde175b504b00ce2ff0b8f4adc3649"}, + {file = "Sphinx-3.3.1-py3-none-any.whl", hash = "sha256:d4e59ad4ea55efbb3c05cde3bfc83bfc14f0c95aa95c3d75346fcce186a47960"}, + {file = "Sphinx-3.3.1.tar.gz", hash = "sha256:1e8d592225447104d1172be415bc2972bd1357e3e12fdc76edf2261105db4300"}, ] sphinxcontrib-applehelp = [ {file = "sphinxcontrib-applehelp-1.0.2.tar.gz", hash = "sha256:a072735ec80e7675e3f432fcae8610ecf509c5f1869d17e2eecff44389cdbc58"}, @@ -1162,8 +1162,8 @@ typed-ast = [ {file = "typed_ast-1.4.1.tar.gz", hash = "sha256:8c8aaad94455178e3187ab22c8b01a3837f8ee50e09cf31f1ba129eb293ec30b"}, ] urllib3 = [ - {file = "urllib3-1.25.11-py2.py3-none-any.whl", hash = "sha256:f5321fbe4bf3fefa0efd0bfe7fb14e90909eb62a48ccda331726b4319897dd5e"}, - {file = "urllib3-1.25.11.tar.gz", hash = "sha256:8d7eaa5a82a1cac232164990f04874c594c9453ec55eef02eab885aa02fc17a2"}, + {file = "urllib3-1.26.1-py2.py3-none-any.whl", hash = "sha256:61ad24434555a42c0439770462df38b47d05d9e8e353d93ec3742900975e3e65"}, + {file = "urllib3-1.26.1.tar.gz", hash = "sha256:097116a6f16f13482d2a2e56792088b9b2920f4eb6b4f84a2c90555fb673db74"}, ] webargs = [ {file = "webargs-6.1.1-py2.py3-none-any.whl", hash = "sha256:2ead6ce38559152043ab4ada4d389aef6c804b0c169482e7c92b3f498f690b2c"}, diff --git a/pyproject.toml b/pyproject.toml index a027c8c2..836e1dfd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,7 +29,7 @@ packages = [ ] [tool.poetry.dependencies] -python = "^3.6" +python = "^3.6.1" Flask = "^1.0" numpy = "1.19.2" # Exact version so we can guarantee a wheel Pillow = "^7.2.0" @@ -44,13 +44,14 @@ expiringdict = "^1.2.1" camera-stage-mapping = "0.1.4" picamerax = ">=20.9.1" pyyaml = "^5.3.1" -labthings = "^1.1.2" +labthings = "^1.1.3" [tool.poetry.extras] rpi = ["RPi.GPIO"] [tool.poetry.scripts] build_static = 'openflexure_microscope.install:build' +rescue = 'openflexure_microscope.rescue.auto:main' [tool.poetry.dev-dependencies] pynpm = "^0.1.2" @@ -71,5 +72,5 @@ ensure_newline_before_comments = true line_length = 88 [tool.pylint.'MESSAGES CONTROL'] -disable = "C0330, C0326, W1202, W0511, C, R" -max-line-length = 88 \ No newline at end of file +disable = "fixme,C,R" +max-line-length = 88 diff --git a/tests/api_client.py b/tests/api_client.py deleted file mode 100644 index 43ad4942..00000000 --- a/tests/api_client.py +++ /dev/null @@ -1,101 +0,0 @@ -from io import BytesIO - -import numpy as np -import requests -from PIL import Image - - -class APIconnection: - def __init__(self, host="localhost", port=5000, api_ver="v1"): - self.base = self.build_base(host=host, port=port, api_ver=api_ver) - - def build_base(self, host, port, api_ver): - return "http://{}:{}/api/{}".format(host, port, api_ver) - - def uri(self, suffix): - return self.base + suffix - - def get(self, route, json=True, timeout=5): - r = requests.get(self.uri(route), timeout=timeout) - if json: - return r.json() - else: - return r - - def post(self, route, json=None, timeout=5): - r = requests.post(self.uri(route), json=json, timeout=timeout) - return r.json() - - def delete(self, route, timeout=5): - r = requests.delete(self.uri(route), timeout=timeout) - return r.json() - - def set_overlay(self, message="", size=50): - json = {"text": message, "size": size} - return self.post("/camera/overlay", json=json) - - def get_overlay(self): - return self.get("/camera/overlay") - - def get_config(self): - return self.get("/config") - - def set_config(self, config_dict): - return self.post("/config", json=config_dict) - - def get_state(self): - return self.get("/state") - - def start_preview(self): - return self.post("/camera/preview/start") - - def stop_preview(self): - return self.post("/camera/preview/stop") - - def move_by(self, x=0, y=0, z=0): - json = {"x": x, "y": y, "z": z} - return self.post("/stage/position", json=json) - - def new_capture(self, use_video_port=True, keep_on_disk=False, resize=None): - json = {"keep_on_disk": keep_on_disk, "use_video_port": use_video_port} - - if resize: - json["size"] = {"width": resize[0], "height": resize[1]} - - return self.post("/camera/capture", json=json) - - def get_capture(self, capture_id): - uri_route = "/camera/capture/{}/download".format(capture_id) - r = self.get(uri_route, json=False) - img = Image.open(BytesIO(r.content)) - array = np.asarray(img, dtype=np.int32) - return array - - def del_capture(self, capture_id): - uri_route = "/camera/capture/{}".format(capture_id) - return self.delete(uri_route) - - def capture( - self, - use_video_port=True, - keep_on_disk=False, - delete_after_use=True, - resize=None, - ): - p = self.new_capture( - use_video_port=use_video_port, keep_on_disk=keep_on_disk, resize=resize - ) - capture_id = p["metadata"]["id"] - img_array = self.get_capture(capture_id) - - if delete_after_use: - self.del_capture(capture_id) - - return img_array - - def set_zoom(self, zoom_value=1.0): - json = {"zoom_value": zoom_value} - return self.post("/camera/zoom", json=json) - - def get_zoom(self): - return self.get("/camera/zoom") diff --git a/tests/test_api.py b/tests/test_api.py deleted file mode 100644 index c7b38e35..00000000 --- a/tests/test_api.py +++ /dev/null @@ -1,112 +0,0 @@ -#!/usr/bin/env python -import logging -import sys -import unittest - -import numpy as np -from api_client import APIconnection - -logging.basicConfig(stream=sys.stderr, level=logging.INFO) - - -class TestCapture(unittest.TestCase): - def test_capture_config(self): - connection = APIconnection(host="localhost", port=5000, api_ver="v1") - config = connection.get_config() - - expected_keys = ["image_resolution", "stream_resolution", "numpy_resolution"] - - for key in expected_keys: - self.assertTrue(key in config) - - def test_capture_videoport(self): - connection = APIconnection(host="localhost", port=5000, api_ver="v1") - resolution = connection.get_config()["stream_resolution"] - - for resize in [None, (640, 480)]: - - if resize: - resolution = resize - - capture_array = connection.capture( - use_video_port=True, - keep_on_disk=False, - delete_after_use=True, - resize=resize, - ) - - self.assertTrue(capture_array.shape == (resolution[1], resolution[0], 3)) - - def test_capture_full(self): - connection = APIconnection(host="localhost", port=5000, api_ver="v1") - resolution = connection.get_config()["image_resolution"] - - for resize in [None, (640, 480)]: - - if resize: - resolution = resize - - capture_array = connection.capture( - use_video_port=False, - keep_on_disk=False, - delete_after_use=True, - resize=resize, - ) - - self.assertTrue(capture_array.shape == (resolution[1], resolution[0], 3)) - - -class TestStage(unittest.TestCase): - def test_stage_config(self): - connection = APIconnection(host="localhost", port=5000, api_ver="v1") - config = connection.get_config() - - expected_keys = ["backlash"] - - for key in expected_keys: - self.assertTrue(key in config) - - def test_stage_state(self): - connection = APIconnection(host="localhost", port=5000, api_ver="v1") - state = connection.get_state() - - self.assertTrue("stage" in state) - - expected_keys = ["position"] - - for key in expected_keys: - self.assertTrue(key in state["stage"]) - - def test_stage_movement(self): - connection = APIconnection(host="localhost", port=5000, api_ver="v1") - - move_distance = 500 - for axis in range(3): - for direction in [1, -1]: - pos_i_dict = connection.get_state()["stage"]["position"] - pos_i = [pos_i_dict["x"], pos_i_dict["y"], pos_i_dict["z"]] - - move = [0, 0, 0] - move[axis] = move_distance * direction - - connection.move_by(*move) - - pos_f_dict = connection.get_state()["stage"]["position"] - pos_f = [pos_f_dict["x"], pos_f_dict["y"], pos_f_dict["z"]] - - diff = np.subtract(pos_f, pos_i) - logging.debug("{} > {}".format(pos_i, pos_f)) - - self.assertTrue(np.array_equal(diff, move)) - - -if __name__ == "__main__": - - suites = [ - unittest.TestLoader().loadTestsFromTestCase(TestCapture), - unittest.TestLoader().loadTestsFromTestCase(TestStage), - ] - - alltests = unittest.TestSuite(suites) - - result = unittest.TextTestRunner(verbosity=2).run(alltests) diff --git a/tests/test_camera.py b/tests/test_camera.py deleted file mode 100644 index 1d2723fb..00000000 --- a/tests/test_camera.py +++ /dev/null @@ -1,266 +0,0 @@ -#!/usr/bin/env python -import io -import logging -import os -import sys -import time -import unittest - -import numpy as np -from PIL import Image - -from openflexure_microscope.camera.pi import CaptureObject, PiCameraStreamer - -logging.basicConfig(stream=sys.stderr, level=logging.DEBUG) - - -class TestCaptureMethods(unittest.TestCase): - def test_still_capture(self): - """Tests capturing still images to a BytesIO stream.""" - global camera - - for use_video_port in [True, False]: - for resize in [None, (640, 480)]: - - # Wait for camera - camera.wait_for_camera() - - # Capture to a context (auto-deletes files when done) - with camera.new_image() as output: - - camera.capture(output, use_video_port=use_video_port, resize=resize) - - # Ensure file deletion fails and returns False - self.assertFalse(output.delete_file()) - # Ensure capture not stored to file - self.assertFalse(os.path.isfile(output.file)) - - # BEFORE DELETE: Ensure StreamObject 'stream' has - # a valid BytesIO object and byte string - self.assertTrue(isinstance(output.data, io.IOBase)) - self.assertTrue(isinstance(output.binary, (bytes, bytearray))) - - # Save capture to file - output.save_file() - # Check file got saved - self.assertTrue(os.path.isfile(output.file)) - - # Delete file - output.delete_file() - # Check file got deleted - self.assertFalse(os.path.isfile(output.file)) - - # AFTER DELETE: Ensure StreamObject 'stream' has - # a valid BytesIO object and byte string - self.assertTrue(isinstance(output.data, io.IOBase)) - self.assertTrue(isinstance(output.binary, (bytes, bytearray))) - - # Create a PIL image from stream - image = Image.open(output.data) - - # Ensure a valid PIL image was created - self.assertTrue(isinstance(image, Image.Image)) - - # Calculate expected dimensions - if resize: - dims = resize - else: - if use_video_port: - dims = camera.stream_resolution - else: - dims = camera.image_resolution - - # Ensure PIL image size matches expected size - print(image.size, dims) - self.assertTrue(image.size == dims) - - def test_still_store(self): - """Tests capturing still images to a file on disk.""" - global camera - - for use_video_port in [True, False]: - for resize in [None, (640, 480)]: - # Wait for camera - camera.wait_for_camera() - - # Capture - output = camera.capture( - camera.new_image().file, - use_video_port=use_video_port, - resize=resize, - ) - - # Check file got saved - self.assertTrue(os.path.isfile(output.file)) - statinfo = os.stat(output.file) - self.assertTrue(statinfo.st_size > 0) - - # Ensure StreamObject 'stream' has - # a valid BytesIO object and byte string - self.assertTrue(isinstance(output.data, io.IOBase)) - self.assertTrue(isinstance(output.binary, (bytes, bytearray))) - - # Ensure file deletion completes and returns True - self.assertTrue(output.delete_file()) - - # Check file got deleted - self.assertFalse(os.path.isfile(output.file)) - - -class TestUnencodedMethods(unittest.TestCase): - def test_yuv_array(self): - """Tests capturing unencoded YUV data to a Numpy array.""" - global camera - - for use_video_port in [True, False]: - for resize in [None, (640, 480)]: - - print("{}, {}, {}".format(id, resize, use_video_port)) - - # Wait for camera - camera.wait_for_camera() - - # Capture RGB array - yuv = camera.yuv(use_video_port=use_video_port, resize=resize) - - # Ensure capture output is a valid numpy array - self.assertTrue(isinstance(yuv, np.ndarray)) - - # Calculate expected dimensions - if resize: - dims = resize - else: - if use_video_port: - dims = camera.stream_resolution - else: - dims = camera.numpy_resolution - - # Ensure array shape matches expected dimensions - self.assertTrue(yuv.shape == (dims[1], dims[0], 3)) - - def test_rgb_array(self): - """Tests capturing unencoded YUV/RGB data to a Numpy array.""" - global camera - - for use_video_port in [True, False]: - for resize in [None, (640, 480)]: - - print("{}, {}, {}".format(id, resize, use_video_port)) - - # Wait for camera - camera.wait_for_camera() - - # Capture RGB array - rgb = camera.array(use_video_port=use_video_port, resize=resize) - - # Ensure capture output is a valid numpy array - self.assertTrue(isinstance(rgb, np.ndarray)) - - # Calculate expected dimensions - if resize: - dims = resize - else: - if use_video_port: - dims = camera.stream_resolution - else: - dims = camera.numpy_resolution - - # Ensure array shape matches expected dimensions - self.assertTrue(rgb.shape == (dims[1], dims[0], 3)) - - -class TestRecordMethods(unittest.TestCase): - def test_video_record(self): - """Tests recording videos to BytesIO stream, and to file on disk.""" - global camera - - # Wait for camera - camera.wait_for_camera() - - with camera.new_video() as output: - - # Start recording - camera.start_recording(output) - - # Record for 2 seconds - time.sleep(2) - # Stop recording - camera.stop_recording() - - # Check stream - self.assertTrue(isinstance(output.data, io.IOBase)) - self.assertTrue(isinstance(output.binary, (bytes, bytearray))) - - # Check file - statinfo = os.stat(output.file) - self.assertTrue(statinfo.st_size > 0) - - # Log path - temp_path = output.file - - # Check file got deleted on __exit__ - self.assertFalse(os.path.isfile(temp_path)) - - time.sleep(0.25) - - def test_video_store(self): - """Tests recording videos to file on disk, without context manager.""" - global camera - - # Wait for camera - camera.wait_for_camera() - - # Start recording - output = camera.start_recording(camera.new_video()) - - # Record for 2 seconds - time.sleep(2) - # Stop recording - camera.stop_recording() - - # Check file - statinfo = os.stat(output.file) - self.assertTrue(statinfo.st_size > 0) - - # Ensure file deletion completes and returns True - self.assertTrue(output.delete_file()) - - # Check file got deleted - self.assertFalse(os.path.isfile(output.file)) - - -class TestThreadStarting(unittest.TestCase): - def test_restarting_stream(self): - """Tests that a capture call restarts the camera worker thread.""" - global camera - - # Wait for camera - camera.wait_for_camera() - - # Force-stop the camera worker thread (should return True) - self.assertTrue(camera.stop_worker()) - - # Check Pi camera has been disconnected - self.assertTrue(camera.camera) - - # Restart worker thread - self.assertTrue(camera.start_worker()) - - self.assertTrue(camera.camera) - self.assertTrue(camera.thread) - self.assertIsInstance(camera.stream, io.IOBase) - - -if __name__ == "__main__": - with PiCameraStreamer() as camera: - - suites = [ - unittest.TestLoader().loadTestsFromTestCase(TestCaptureMethods), - unittest.TestLoader().loadTestsFromTestCase(TestUnencodedMethods), - unittest.TestLoader().loadTestsFromTestCase(TestThreadStarting), - unittest.TestLoader().loadTestsFromTestCase(TestRecordMethods), - ] - - alltests = unittest.TestSuite(suites) - - result = unittest.TextTestRunner(verbosity=2).run(alltests) diff --git a/tests/test_plugins.py b/tests/test_plugins.py deleted file mode 100644 index 431e70a8..00000000 --- a/tests/test_plugins.py +++ /dev/null @@ -1,44 +0,0 @@ -#!/usr/bin/env python -import atexit -import logging -import sys -import unittest - -from openflexure_stage import OpenFlexureStage - -from openflexure_microscope import Microscope, config -from openflexure_microscope.camera.pi import PiCameraStreamer - -logging.basicConfig(stream=sys.stderr, level=logging.DEBUG) - - -class TestPluginMethods(unittest.TestCase): - def test_plugin_load(self): - plugin_arr = microscope.plugins.plugins - - plugin_names = [plugin[0] for plugin in plugin_arr] - - self.assertTrue("testing" in plugin_names) - - def test_camera_access(self): - identify = microscope.plugins.testing.identify() - self.assertTrue(identify[0] is microscope.camera) - - def test_stage_access(self): - identify = microscope.plugins.testing.identify() - self.assertTrue(identify[1] is microscope.stage) - - -if __name__ == "__main__": - - with Microscope() as microscope: - - microscope.attach(PiCameraStreamer(), OpenFlexureStage()) - - microscope.plugins.attach("openflexure_microscope.plugins.testing:Plugin") - - suites = [unittest.TestLoader().loadTestsFromTestCase(TestPluginMethods)] - - alltests = unittest.TestSuite(suites) - - result = unittest.TextTestRunner(verbosity=2).run(alltests) diff --git a/tests/test_stage.py b/tests/test_stage.py deleted file mode 100644 index 90ee10d4..00000000 --- a/tests/test_stage.py +++ /dev/null @@ -1,42 +0,0 @@ -#!/usr/bin/env python -import logging -import sys -import unittest - -import numpy as np -from openflexure_stage import OpenFlexureStage - -logging.basicConfig(stream=sys.stderr, level=logging.INFO) - - -class TestMicroscope(unittest.TestCase): - def test_movement(self): - move_distance = 500 - for axis in range(3): - for direction in [1, -1]: - pos_i = stage.position - logging.debug(pos_i) - - logging.info( - "Moving axis {} by {}".format(axis, move_distance * direction) - ) - move = [0, 0, 0] - move[axis] = move_distance * direction - - stage.move_rel(move) - - pos_f = stage.position - diff = np.subtract(pos_f, pos_i) - logging.debug("{} > {}".format(pos_i, pos_f)) - - self.assertTrue(np.array_equal(diff, move)) - - -if __name__ == "__main__": - with OpenFlexureStage("/dev/ttyUSB0") as stage: - - suites = [unittest.TestLoader().loadTestsFromTestCase(TestMicroscope)] - - alltests = unittest.TestSuite(suites) - - result = unittest.TextTestRunner(verbosity=2).run(alltests)