diff --git a/openflexure_microscope/__init__.py b/openflexure_microscope/__init__.py index a68927d6..3dc1f76b 100644 --- a/openflexure_microscope/__init__.py +++ b/openflexure_microscope/__init__.py @@ -1 +1 @@ -__version__ = "0.1.0" \ No newline at end of file +__version__ = "0.1.0" diff --git a/openflexure_microscope/api/app.py b/openflexure_microscope/api/app.py index aea3bf4d..4a109823 100644 --- a/openflexure_microscope/api/app.py +++ b/openflexure_microscope/api/app.py @@ -1,25 +1,29 @@ #!/usr/bin/env python -#TODO: Completely rewrite for new version of StreamingCamera + from pprint import pprint from importlib import import_module -import time, datetime +import time +import datetime + +from flask import ( + Flask, render_template, Response, + redirect, request, jsonify, send_file) -from flask import Flask, render_template, Response, redirect, request, jsonify, send_file -#import yaml import numpy as np -# Raspberry Pi camera module (requires picamera package) -#from camera.pi import Camera from openflexure_microscope.camera.pi import StreamingCamera app = Flask(__name__) cam = StreamingCamera() + def parse_payload(request): - # TODO: Try-except for invalid JSON payloads + """Convert request to JSON. Will eventually handle error-checking.""" + # TODO: Handle invalid JSON payloads state = request.get_json() return state + def gen(camera): """Video streaming generator function.""" while True: @@ -29,31 +33,29 @@ def gen(camera): yield (b'--frame\r\n' b'Content-Type: image/jpeg\r\n\r\n' + frame + b'\r\n') + @app.route('/') def index(): - ''' - Video streaming home page. - ''' + """Video streaming home page.""" cam.start_worker() # Start the stream return render_template('index.html') + @app.route('/stream') def stream(): - ''' - Video streaming route. Put this in the src attribute of an img tag. - ''' - return Response(gen(cam), mimetype='multipart/x-mixed-replace; boundary=frame') + """Video streaming route. Put this in the src attribute of an img tag.""" + return Response( + gen(cam), + mimetype='multipart/x-mixed-replace; boundary=frame') -#TODO: Be able to change the image resolution with an option +# TODO: Be able to change the image resolution with an option @app.route('/capture/', methods=['GET', 'POST', 'PUT']) def capture(): - ''' - POST/PUT: Capture a single image - GET: Return capture status, or download latest capture - - Eg. curl http://192.168.1.140:5000/capture/ -H "Content-Type: application/json" -d '{"store":true}' -X POST - ''' + """ + POST/PUT: Capture a single image. + GET: Return capture status, or download latest capture. + """ if request.method == 'POST' or request.method == 'PUT': state = parse_payload(request) print(state) @@ -63,7 +65,7 @@ def capture(): filename = state['filename'] else: filename = None - + if 'write_to_file' in state: write_to_file = bool(state['write_to_file']) else: @@ -73,7 +75,7 @@ def capture(): use_video_port = bool(state['use_video_port']) else: use_video_port = False - + if 'resize' in state: resize_h = int(state['resize']) resize_w = int(resize_h*(4/3)) @@ -82,7 +84,7 @@ def capture(): resize = None response = cam.capture( - write_to_file=write_to_file, + write_to_file=write_to_file, use_video_port=use_video_port, filename=filename, resize=resize) @@ -94,22 +96,24 @@ def capture(): download = request.args.get('download') state = cam.state - - if (download == 'true' or download == 'True' or download =='1') and cam.image is not None: + + if (( + download == 'true' or + download == 'True' or + download == '1') and + cam.image is not None): + return send_file(cam.image.data, mimetype='image/jpeg') else: return jsonify(state) + @app.route('/record/', methods=['GET', 'POST', 'PUT']) def record(): - ''' - POST/PUT: Start or stop a video recording + """ + POST/PUT: Start or stop a video recording. GET: Return recording status, or download latest recording - - Eg. - curl http://192.168.1.140:5000/record/ -H "Content-Type: application/json" -d '{"status":true}' -X POST - curl http://192.168.1.140:5000/record/ -H "Content-Type: application/json" -d '{"status":false}' -X POST - ''' + """ if request.method == 'POST' or request.method == 'PUT': state = request.get_json() print(state) @@ -125,32 +129,40 @@ def record(): status = bool(state['status']) # synchronise the arduino_time - if status == True: + if status is True: response = cam.start_recording(filename=filename) return str(response) - elif status == False: + elif status is False: response = cam.stop_recording() return str(response) - + else: download = request.args.get('download') state = cam.state - - if (download == 'true' or download == 'True' or download =='1') and cam.state_record['recent'] is not None: - return send_file(cam.state_record['recent'], + + if (( + download == 'true' or + download == 'True' or + download == '1') and + cam.state['recent_video'] is not None): + + return send_file( + cam.state['recent_video'], mimetype='video/H264', as_attachment=True) else: return jsonify(state) + @app.route('/preview/', methods=['GET', 'POST', 'PUT']) def preview(): - ''' - POST/PUT: Start or stop the direct-to-GPU camera preview on the Pi + """ + POST/PUT: Start or stop the direct-to-GPU camera preview on the Pi. + GET: Return preview status - ''' + """ if request.method == 'POST' or request.method == 'PUT': state = request.get_json() print(state) @@ -158,13 +170,13 @@ def preview(): if 'status' in state: status = bool(state['status']) - if status == False: + if status is False: response = cam.stop_preview() else: response = cam.start_preview() return str(response) - + else: # Get args passed via URL (not useful here. Just for reference.) state = cam.state @@ -172,4 +184,4 @@ def preview(): if __name__ == '__main__': - app.run(host='0.0.0.0', threaded=True, debug=True, use_reloader=False) \ No newline at end of file + app.run(host='0.0.0.0', threaded=True, debug=True, use_reloader=False) diff --git a/openflexure_microscope/camera/base.py b/openflexure_microscope/camera/base.py index 0a129800..96e1b024 100644 --- a/openflexure_microscope/camera/base.py +++ b/openflexure_microscope/camera/base.py @@ -17,6 +17,7 @@ except ImportError: # Slightly crappy debugger logging DEBUG = True + def log(s): if DEBUG: print('DEBUG: {}'.format(s)) @@ -71,13 +72,13 @@ class StreamObject(object): file_path = os.path.join(folder, file_name) else: file_path = file_name - + return file_path - + def lock(self): """Set locked flag to True""" self.locked = True - + def unlock(self): """Set locked flag to False""" self.locked = False @@ -111,6 +112,11 @@ class StreamObject(object): f.seek(0, 0) # Seek to the start of the file f.write(self.binary) # Write data bytes to file + def clear_stream(self): + """Clears the BytesIO stream of the StreamObject.""" + self.stream = io.BytesIO() + return True + def delete(self) -> bool: """ If the StreamObject has been saved to disk, deletes the file and returns True. diff --git a/openflexure_microscope/camera/config.py b/openflexure_microscope/camera/config.py index 7aa3d30c..93cf25e7 100644 --- a/openflexure_microscope/camera/config.py +++ b/openflexure_microscope/camera/config.py @@ -16,22 +16,19 @@ TYPES = { 'digital_gain': float, } + def convert_config(config): - """Convert datatype of config based on type dictionary""" + """Convert datatype of config based on type dictionary.""" global TYPES for key in config: if key in TYPES: config[key] = TYPES[key](config[key]) - + return config + def load_config(yaml_path): + """Load YAML file, pass through dictionary conversion, and return.""" with open(yaml_path) as config_file: return convert_config(yaml.load(config_file)) - -if __name__ == "__main__": - from pprint import pprint - - config = load_config('config_picamera.yaml') - pprint(config) \ No newline at end of file diff --git a/openflexure_microscope/camera/pi.py b/openflexure_microscope/camera/pi.py index 6eb12bbc..b70bbe44 100644 --- a/openflexure_microscope/camera/pi.py +++ b/openflexure_microscope/camera/pi.py @@ -1,4 +1,5 @@ # -*- coding: utf-8 -*- + """ Raspberry Pi camera implementation of the StreamingCamera class. @@ -14,7 +15,7 @@ Video port: StreamingCamera streams at video_resolution Camera capture resolution set to video_resolution in frames() Video port uses that resolution for everything. If a different resolution -is specified for video capture, this is handled by the resizer. +is specified for video capture, this is handled by the resizer. Still capture (if use_video_port == False) uses pause_stream_for_capture to temporarily increase the capture resolution. @@ -51,8 +52,9 @@ from .config import load_config HERE = os.path.abspath(os.path.dirname(__file__)) DEFAULT_CONFIG = os.path.join(HERE, 'config_picamera.yaml') -class StreamingCamera(BaseCamera): +class StreamingCamera(BaseCamera): + """Raspberry Pi camera implementation of StreamingCamera.""" def __init__(self): # Capture data self.image = None @@ -79,10 +81,11 @@ class StreamingCamera(BaseCamera): BaseCamera.__init__(self) def initialisation(self): + """Run any initialisation code when the frame iterator starts.""" pass def start_preview(self) -> bool: - """Function to start the onboard GPU camera preview.""" + """Start the onboard GPU camera preview.""" self.start_worker() self.camera.start_preview() @@ -90,7 +93,7 @@ class StreamingCamera(BaseCamera): return True def stop_preview(self) -> bool: - """Function to start the onboard GPU camera preview.""" + """Stop the onboard GPU camera preview.""" self.start_worker() self.camera.stop_preview() @@ -98,13 +101,14 @@ class StreamingCamera(BaseCamera): return True # TODO: Handle exceptions + # TODO: Have this take a dictionary (not a config file) + # TODO: Web API entry point to send settings as JSON to this method + # TODO: Separate method to store current settings back to YAML file + # TODO: API entry point to get settings to JSON def update_settings(self, config_path: str=None) -> None: - """ - Opens config_picamera.yaml file and writes valid settings - to the camera hardware object - """ + """Open config_picamera.yaml file and write to camera.""" global DEFAULT_CONFIG - + self.start_worker() if not config_path: @@ -162,7 +166,7 @@ class StreamingCamera(BaseCamera): centre = np.array([fov[0] + fov[2]/2.0, fov[1] + fov[3]/2.0]) size = 1.0/zoom_value # If the new zoom value would be invalid, move the centre to - # keep it within the camera's sensor (this is only relevant + # keep it within the camera's sensor (this is only relevant # when zooming out, if the FoV is not centred on (0.5, 0.5) for i in range(2): if np.abs(centre[i] - 0.5) + size/2 > 0.5: @@ -203,27 +207,40 @@ class StreamingCamera(BaseCamera): quality: int=15): """Start a new video recording, writing to a target object. - target (str/BytesIO): Target object to write bytes to (default StreamObject) - write_to_file (bool/NoneType): Should the StreamObject write to a file? (default True for video capture) - filename (str): Name of the stored file (defaults to timestamp) + target (str/BytesIO): Target object to write bytes to. + (default StreamObject) + write_to_file (bool/NoneType): Should the StreamObject write to a file? + (default True for video capture) + filename (str): Name of the stored file. + (defaults to timestamp) folder (str): Relative directory to store data file in. - fmt (str): Format of the capture (default 'h264') + fmt (str): Format of the capture. + (default 'h264') """ self.start_worker() # If no target is specified, store to StreamingCamera if not target: - if (self.video is None) or (not self.video.locked): # If target is not locked + # If target is not locked + if (self.video is None) or (not self.video.locked): - self.video = StreamObject(write_to_file=write_to_file, filename=filename, folder=folder, fmt=fmt) # Reset/create StreamObject + self.video = StreamObject( + write_to_file=write_to_file, + filename=filename, + folder=folder, + fmt=fmt) # Reset/create StreamObject - target = self.video.target # Store to the StreamObject BytesIO - target_obj = self.video # Note the target StreamObject, used for function return + # Store to the StreamObject BytesIO + target = self.video.target + # Note the target StreamObject, used for function return + target_obj = self.video target_obj.lock() # Lock the StreamObject while recording else: - print("Cannot start recording a new video until the current recording has been stopped. Returning None.") + print("Cannot start recording a new video \ + until the current recording has been stopped. \ + Returning None.") return None else: target_obj = target @@ -247,7 +264,8 @@ class StreamingCamera(BaseCamera): Pause capture on a splitter port. splitter_port (int): Splitter port to stop recording on - resolution ((int, int)): Resolution to set the camera to, after stopping recording. + resolution ((int, int)): Resolution to set the camera to, + after stopping recording. """ log("Pausing stream") # If no resolution is specified, default to image_resolution @@ -262,10 +280,11 @@ class StreamingCamera(BaseCamera): def resume_stream_for_capture(self, splitter_port: int=1, resolution: Tuple[int, int]=None) -> None: """ - Resume capture on a splitter port + Resume capture on a splitter port. splitter_port (int): Splitter port to start recording on - resolution ((int, int)): Resolution to set the camera to, before starting recording. + resolution ((int, int)): Resolution to set the camera to, + before starting recording. """ log("Unpausing stream") if not resolution: @@ -291,17 +310,21 @@ class StreamingCamera(BaseCamera): fmt: str='jpeg', resize: Tuple[int, int]=None): """ - Captures a still image to a StreamObject. + Capture a still image to a StreamObject. Defaults to JPEG format. Target object can be overridden for development purposes. - target (str/BytesIO): Target object to write data bytes to - write_to_file (bool): Should the StreamObject write to a file, instead of BytesIO stream? - use_video_port (bool): Capture from the video port used for streaming (lower resolution, faster) - filename (str): Name of the stored file (defaults to timestamp) + target (str/BytesIO): Target object to write data bytes to. + write_to_file (bool): Should the StreamObject write to a file, + instead of BytesIO stream? + use_video_port (bool): Capture from the video port used for streaming. + (lower resolution, faster) + filename (str): Name of the stored file. + (defaults to timestamp) folder (str): Relative directory to store data file in. - fmt (str): Format of the capture (default 'h264') + fmt (str): Format of the capture. + (default 'h264') resize ((int, int)): Resize the captured image. """ self.start_worker() @@ -348,7 +371,7 @@ class StreamingCamera(BaseCamera): return target_obj def array(self, rgb=False, use_video_port: bool=True, resize: Tuple[int, int]=None) -> np.ndarray: - """Captures an uncompressed still YUV image to a Numpy array. + """Capture an uncompressed still YUV image to a Numpy array. use_video_port (bool): Capture from the video port used for streaming (lower resolution, faster) resize ((int, int)): Resize the captured image.