commit 2f4f57a59b0f230042c92409c0fade891a37f635 Author: Joel Collins Date: Tue Nov 6 11:35:07 2018 +0000 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..871475f8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,63 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] + +# C extensions +*.so + +# Distribution / packaging +.Python +env/ +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +*.egg-info/ +.installed.cfg +*.egg + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.cache +nosetests.xml +coverage.xml + +# Translations +*.mo +*.pot + +# Django stuff: +*.log + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +#Big-o files +capture/ +record/ +*.data + +#IDE files +.vscode/ +.idea/ diff --git a/README.md b/README.md new file mode 100644 index 00000000..00dcb229 --- /dev/null +++ b/README.md @@ -0,0 +1,5 @@ +OpenFlexure Microscope Software +===================== + +## Video streaming +Based on supporting code for the article [video streaming with Flask](http://blog.miguelgrinberg.com/post/video-streaming-with-flask) and its follow-up [Flask Video Streaming Revisited](http://blog.miguelgrinberg.com/post/flask-video-streaming-revisited). diff --git a/activate b/activate new file mode 100644 index 00000000..17547932 --- /dev/null +++ b/activate @@ -0,0 +1 @@ +source env/bin/activate \ No newline at end of file diff --git a/openflexure_microscope/__init__.py b/openflexure_microscope/__init__.py new file mode 100644 index 00000000..a68927d6 --- /dev/null +++ b/openflexure_microscope/__init__.py @@ -0,0 +1 @@ +__version__ = "0.1.0" \ No newline at end of file diff --git a/openflexure_microscope/api/app.py b/openflexure_microscope/api/app.py new file mode 100644 index 00000000..aea3bf4d --- /dev/null +++ b/openflexure_microscope/api/app.py @@ -0,0 +1,175 @@ +#!/usr/bin/env python +#TODO: Completely rewrite for new version of StreamingCamera +from pprint import pprint +from importlib import import_module +import time, datetime + +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 + state = request.get_json() + return state + +def gen(camera): + """Video streaming generator function.""" + while True: + # the obtained frame is a jpeg + frame = camera.get_frame() + + 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. + ''' + 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') + + +#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 + ''' + if request.method == 'POST' or request.method == 'PUT': + state = parse_payload(request) + print(state) + + # Handle filename argument + if 'filename' in state: + filename = state['filename'] + else: + filename = None + + if 'write_to_file' in state: + write_to_file = bool(state['write_to_file']) + else: + write_to_file = False + + if 'use_video_port' in state: + 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)) + resize = (resize_w, resize_h) + else: + resize = None + + response = cam.capture( + write_to_file=write_to_file, + use_video_port=use_video_port, + filename=filename, + resize=resize) + + return str(response) + "\n" + + else: # If GET request + + download = request.args.get('download') + + state = cam.state + + 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 + 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) + + # Handle filename argument + if 'filename' in state: + filename = state['filename'] + else: + filename = None + + # Handle status argument + if 'status' in state: + status = bool(state['status']) + + # synchronise the arduino_time + if status == True: + response = cam.start_recording(filename=filename) + return str(response) + + elif status == 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'], + 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 + GET: Return preview status + ''' + if request.method == 'POST' or request.method == 'PUT': + state = request.get_json() + print(state) + + if 'status' in state: + status = bool(state['status']) + + if status == 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 + return jsonify(state) + + +if __name__ == '__main__': + app.run(host='0.0.0.0', threaded=True, debug=True, use_reloader=False) \ No newline at end of file diff --git a/openflexure_microscope/api/templates/index.html b/openflexure_microscope/api/templates/index.html new file mode 100644 index 00000000..d1f67f86 --- /dev/null +++ b/openflexure_microscope/api/templates/index.html @@ -0,0 +1,9 @@ + + + Video Streaming Demonstration + + +

Video Streaming Demonstration

+ + + diff --git a/openflexure_microscope/camera/__init__.py b/openflexure_microscope/camera/__init__.py new file mode 100644 index 00000000..adfe60a7 --- /dev/null +++ b/openflexure_microscope/camera/__init__.py @@ -0,0 +1 @@ +from . import pi, base \ No newline at end of file diff --git a/openflexure_microscope/camera/base.py b/openflexure_microscope/camera/base.py new file mode 100644 index 00000000..0a129800 --- /dev/null +++ b/openflexure_microscope/camera/base.py @@ -0,0 +1,275 @@ +import time +import os +import io +import threading +import copy +import datetime +from PIL import Image + +try: + from greenlet import getcurrent as get_ident +except ImportError: + try: + from thread import get_ident + except ImportError: + from _thread import get_ident + +# Slightly crappy debugger logging +DEBUG = True + +def log(s): + if DEBUG: + print('DEBUG: {}'.format(s)) + + +class StreamObject(object): + def __init__(self, write_to_file: bool=None, filename: str=None, folder: str=None, fmt: str='file') -> None: + # Store a nice ID + self.id = filename + log("Created {}".format(self.id)) + + # Create file name + self.file = self.build_file_path(filename, folder, fmt) + + # Byte stream properties + self.stream = io.BytesIO() # Byte stream that data will be written to + + # Set default write target + self.write_to_file = write_to_file + if self.write_to_file == False: + log("Default target for {} set to 'stream'".format(self.id)) + self.target = self.stream + else: + log("Default target for {} set to 'file'".format(self.id)) + self.target = self.file + + # Object lock + self.locked = False # Is the StreamObject locked for writing? (Handled by StreamingCamera) + + def __enter__(self): + log("Entering context for {}. Stored files will be cleaned up automatically.".format(self.id)) + return self + + def __exit__(self, *args): + log("Cleaning up {}".format(self.id)) + self.delete() + + def build_file_path(self, filename: str, folder: str, fmt: str) -> str: + """ + Construct a full file path, based on filename, folder, and file format. Defaults to datestamp + """ + if filename: + file_name = "{}.{}".format(filename, fmt) + else: + file_name = "{}.{}".format(datetime.datetime.now().strftime("%Y-%m-%d_%H-%M-%S"), fmt) + + # Create folder and file + if folder: + if not os.path.exists(folder): + os.mkdir(folder) + + 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 + + @property + def data(self) -> io.BytesIO: + """Return a byte string of the capture data""" + self.stream.seek(0) # Rewind the data bytes for reading + + if not self.stream.getvalue() and os.path.isfile(self.file): # If stream is empty but file is not + log("No stream data. Opening from file {}".format(self.file)) + with open(self.file, 'rb') as f: + self.stream = io.BytesIO(f.read()) # Load bytes from file in disk + self.stream.seek(0) # Rewind data bytes again + + data = self.stream.getbuffer() # Create a copy of the stream bytes + + return io.BytesIO(data) # Read and return bytes data + + @property + def binary(self) -> bytes: + """Return a byte string of the capture data""" + return self.data.getvalue() + + def save(self): + """ + Creates/opens a file, and writes this StreamObjects bytestring to the file. + """ + with open(self.file, 'ab') as f: # Load file as bytes + log("Writing stream to file {}".format(self.file)) + f.seek(0, 0) # Seek to the start of the file + f.write(self.binary) # Write data bytes to file + + def delete(self) -> bool: + """ + If the StreamObject has been saved to disk, deletes the file and returns True. + """ + if os.path.isfile(self.file): + log("Deleting file {}".format(self.file)) + os.remove(self.file) + return True + else: + log("File not found {}".format(self.file)) + return False + + +class CameraEvent(object): + """An Event-like class that signals all active clients when a new frame is + available. + """ + def __init__(self): + self.events = {} + + def wait(self, timeout: int=5): + """Invoked from each client's thread to wait for the next frame.""" + ident = get_ident() + if ident not in self.events: + # this is a new client + # add an entry for it in the self.events dict + # each entry has two elements, a threading.Event() and a timestamp + self.events[ident] = [threading.Event(), time.time()] + return self.events[ident][0].wait(timeout) + + def set(self): + """Invoked by the camera thread when a new frame is available.""" + now = time.time() + remove = None + for ident, event in self.events.items(): + if not event[0].isSet(): + # if this client's event is not set, then set it + # also update the last set timestamp to now + event[0].set() + event[1] = now + else: + # if the client's event is already set, it means the client + # did not process a previous frame + # if the event stays set for more than 5 seconds, then assume + # the client is gone and remove it + if now - event[1] > 5: + remove = ident + if remove: + del self.events[remove] + + def clear(self): + """Invoked from each client's thread after a frame was processed.""" + self.events[get_ident()][0].clear() + + +class BaseCamera(object): + thread = None # Background thread that reads frames from camera + camera = None # Camera object, for direct access to camera + + frame = None # Current frame is stored here by background thread + last_access = 0 # Time of last client access to the camera + event = CameraEvent() + + def __init__(self): + self.start_worker() + + # Stores state of StreamingCamera + self.state = { + } + + def start_worker(self, timeout: int=5) -> bool: + """Start the background camera thread if it isn't running yet.""" + timeout_time = time.time() + timeout + + self.last_access = time.time() + self.stop = False + + if self.thread is None: + # start background frame thread + self.thread = threading.Thread(target=self._thread) + self.thread.start() + + # wait until frames are available + log("Waiting for frames") + while self.get_frame() is None: + if time.time() > timeout_time: + raise TimeoutError("Timeout waiting for frames.") + break + else: + time.sleep(0) + return True + + def stop_worker(self, timeout: int=5) -> bool: + """Flags worker thread for stop. Waits for thread to close, or times out.""" + timeout_time = time.time() + timeout + + self.stop = True + while self.thread: + if time.time() > timeout_time: + raise TimeoutError("Timeout waiting for worker thread to close.") + break + else: + time.sleep(0) + return True + + def get_frame(self): + """Return the current camera frame.""" + self.last_access = time.time() + + # wait for a signal from the camera thread + self.event.wait() + self.event.clear() + + return self.frame + + def frames(self): + """Generator that returns frames from the camera.""" + raise RuntimeError('Must be implemented by subclasses.') + + def close(self): + """Handles closing the StreamingCamera""" + self.stop_worker() + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_value, traceback): + """Close camera stream on context exit.""" + self.close() + + def _thread(self): + """Camera background thread.""" + self.frames_iterator = self.frames() + + # Update state + self.state['stream_active'] = True + + for frame in self.frames_iterator: + self.frame = frame + self.event.set() # send signal to clients + time.sleep(0) + + # if there hasn't been any clients asking for frames in + # the last 10 seconds then stop the thread + if time.time() - self.last_access > 20: + self.frames_iterator.close() + break + + try: + if self.stop is True: + self.frames_iterator.close() + break + + except AttributeError: + pass + + # Reset thread + self.thread = None + # Destroy any stored camera object (used especially for handling Pi cameras) + self.camera = None + # Update state + self.state['stream_active'] = False \ No newline at end of file diff --git a/openflexure_microscope/camera/config.py b/openflexure_microscope/camera/config.py new file mode 100644 index 00000000..7aa3d30c --- /dev/null +++ b/openflexure_microscope/camera/config.py @@ -0,0 +1,37 @@ +import yaml + +TYPES = { + 'stream_resolution': tuple, + 'video_resolution': tuple, + 'image_resolution': tuple, + 'numpy_resolution': tuple, + 'jpeg_quality': int, + 'framerate': int, + 'awb_mode': str, + 'red_gain': float, + 'blue_gain': float, + 'shutter_speed': int, + 'saturation': int, + 'analog_gain': float, + 'digital_gain': float, +} + +def convert_config(config): + """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): + 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/config_picamera.yaml b/openflexure_microscope/camera/config_picamera.yaml new file mode 100644 index 00000000..b71b2b03 --- /dev/null +++ b/openflexure_microscope/camera/config_picamera.yaml @@ -0,0 +1,35 @@ +# Exposure mode +exposure_mode: 'off' + +# The analog gain offers higher sensitivity and less noise than using digital gain only +analog_gain: 1. +digital_gain: 1. + +# When queried, the shutter_speed property returns the shutter speed of the camera in microseconds, +# or 0 which indicates that the speed will be automatically determined. +# If high value does not work, need to decrease the fps for streaming +shutter_speed: 30000 + +# Auto white balance: The red and blue values are returned Fraction instances. +# The values will be between 0.0 and 8.0. +awb_mode: 'off' + +# If not using auto, then you can change the awb_gains +red_gain: 1.3 +blue_gain: 1.2 + +# Color saturation of the camera as an integer between -100 and 100. +saturation: 0 + +# ISO Valid values are between 0 (auto) and 800 (1600 is not available?). +# ISO value is not used anyway as we are fixing the gains +iso: 500 + +# Resolutions for streaming and capture +video_resolution: [832, 624] +image_resolution: [2592, 1944] +numpy_resolution: [1312, 976] + +# Capture quality +jpeg_quality: 75 +framerate: 24 \ No newline at end of file diff --git a/openflexure_microscope/camera/pi.py b/openflexure_microscope/camera/pi.py new file mode 100644 index 00000000..6eb12bbc --- /dev/null +++ b/openflexure_microscope/camera/pi.py @@ -0,0 +1,433 @@ +# -*- coding: utf-8 -*- +""" +Raspberry Pi camera implementation of the StreamingCamera class. + +NOTES: +Still port used for image capture +Preview port reserved for onboard GPU preview +Video port: + Splitter port 0: Image capture (if use_video_port == True) + Splitter port 1: Streaming frames + Splitter port 2: Video capture + Splitter port 3: [Currently unused] + +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. + +Still capture (if use_video_port == False) uses pause_stream_for_capture +to temporarily increase the capture resolution. +""" + +from __future__ import division + +import io +import time +import datetime +import sys +import os +import yaml +import copy +import numpy as np +from PIL import Image + +# Pi camera +import picamera +import picamera.array + +# Type hinting +from typing import Tuple + +# Threading +import threading + +from .base import BaseCamera, StreamObject, log +# Richard's fix gain +from .set_picamera_gain import set_analog_gain, set_digital_gain +# Manage config +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): + + def __init__(self): + # Capture data + self.image = None + self.video = None + + # Camera settings + self.settings = { + 'video_resolution': (832, 624), + 'image_resolution': (2592, 1944), + 'numpy_resolution': (1312, 976), + 'jpeg_quality': 75, + } + + # Store state of StreamingCamera + self.state = { + 'video_recent': None, + 'image_recent': None, + 'stream_active': False, + 'record_active': False, + 'preview_active': False, + } + + # Finally, run BaseCamera init to start thread + BaseCamera.__init__(self) + + def initialisation(self): + pass + + def start_preview(self) -> bool: + """Function to start the onboard GPU camera preview.""" + self.start_worker() + + self.camera.start_preview() + self.state['preview_active'] = True + return True + + def stop_preview(self) -> bool: + """Function to start the onboard GPU camera preview.""" + self.start_worker() + + self.camera.stop_preview() + self.state['preview_active'] = False + return True + + # TODO: Handle exceptions + def update_settings(self, config_path: str=None) -> None: + """ + Opens config_picamera.yaml file and writes valid settings + to the camera hardware object + """ + global DEFAULT_CONFIG + + self.start_worker() + + if not config_path: + config = load_config(DEFAULT_CONFIG) + else: + config = load_config(config_path) + + log("Applying config:") + log(config) + + # StreamingCamera settings + if 'video_resolution' in config: + self.settings['video_resolution'] = config['video_resolution'] + if 'image_resolution' in config: + self.settings['image_resolution'] = config['image_resolution'] + if 'numpy_resolution' in config: + self.settings['numpy_resolution'] = config['numpy_resolution'] + + if 'jpeg_quality' in config: + self.settings['jpeg_quality'] = config['jpeg_quality'] + if 'framerate' in config: + self.settings['framerate'] = config['framerate'] + + # Camera AWB + if 'awb_mode' in config: + self.camera.awb_mode = config['awb_mode'] + if 'red_gain' in config and 'blue_gain' in config: + self.camera.awb_gains = (config['red_gain'], config['blue_gain']) + + # Camera framerate + if 'framerate' in config: + self.camera.framerate = config['framerate'] + # Camera exposure + if 'shutter_speed' in config: + self.camera.shutter_speed = config['shutter_speed'] + if 'saturation' in config: + self.camera.saturation = config['saturation'] + # Camera misc. + self.camera.led = False + # Richard's library to set analog and digital gains + if 'analog_gain' in config: + set_analog_gain(self.camera, config['analog_gain']) + if 'digital_gain' in config: + set_digital_gain(self.camera, config['digital_gain']) + + # TODO: Rewrite this bit? + # https://picamera.readthedocs.io/en/release-1.13/api_camera.html + def change_zoom(self, zoom_value: int=1) -> None: + """Change the camera zoom, handling recentering and scaling.""" + zoom_value = float(zoom_value) + if zoom_value < 1: + zoom_value = 1 + # Richard's code for zooming ! + fov = self.camera.zoom + 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 + # 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: + centre[i] = 0.5 + (1.0 - size)/2 * np.sign(centre[i]-0.5) + print("setting zoom, centre {}, size {}".format(centre, size)) + new_fov = (centre[0] - size/2, centre[1] - size/2, size, size) + self.camera.zoom = new_fov + + def stop_recording(self, target=None) -> bool: + """Stop the last started video recording on splitter port 2. + + target (str/BytesIO): Target object to write data bytes to. + """ + if not target: # If no target is defined + target_obj = self.video # Assume StreamingCamera as target + target_obj.unlock() # Unlock the StreamObject + else: + target_obj = target + + # Stop the camera video recording on port 2 + log("Stopping recording") + self.camera.stop_recording(splitter_port=2) + log("Recording stopped") + + # Update state dictionary + self.state['record_active'] = False + self.state['video_recent'] = str(target_obj) + + return True + + def start_recording( + self, + target=None, + write_to_file: bool=True, + filename: str=None, + folder: str='record', + fmt: str='h264', + 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) + folder (str): Relative directory to store data file in. + 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 + + 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 + + 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.") + return None + else: + target_obj = target + + # Start the camera video recording on port 2 + log("Starting record at {}".format(self.settings['video_resolution'])) + self.camera.start_recording( + target, + format=fmt, + splitter_port=2, + resize=self.settings['video_resolution'], + quality=quality) + + # Update state dictionary + self.state['record_active'] = True + + return target_obj + + def pause_stream_for_capture(self, splitter_port: int=1, resolution: Tuple[int, int]=None) -> None: + """ + 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. + """ + log("Pausing stream") + # If no resolution is specified, default to image_resolution + if not resolution: + resolution = self.settings['image_resolution'] + + # Stop the camera video recording on port 1 + self.camera.stop_recording(splitter_port=splitter_port) + + # Increase the resolution for taking an image + self.camera.resolution = resolution + + def resume_stream_for_capture(self, splitter_port: int=1, resolution: Tuple[int, int]=None) -> None: + """ + 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. + """ + log("Unpausing stream") + if not resolution: + resolution = self.settings['video_resolution'] + + # Reduce the resolution for video streaming + self.camera.resolution = resolution + + # Resume the video channel + self.camera.start_recording( + self.stream, + format='mjpeg', + quality=self.settings['jpeg_quality'], + splitter_port=splitter_port) + + def capture( + self, + target=None, + write_to_file: bool=False, + use_video_port: bool=False, + filename: str=None, + folder: str='capture', + fmt: str='jpeg', + resize: Tuple[int, int]=None): + """ + Captures 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) + folder (str): Relative directory to store data file in. + fmt (str): Format of the capture (default 'h264') + resize ((int, int)): Resize the captured image. + """ + self.start_worker() + + # If no target is specified, store to StreamingCamera + if not target: + self.image = StreamObject(write_to_file=write_to_file, filename=filename, folder=folder, fmt=fmt) # Reset StreamObject + + target = self.image.target # Store to the StreamObject BytesIO + target_obj = self.image # Note the target StreamObject, used for function return + + else: + target_obj = target + + log("Capturing to {}".format(target)) + + if not use_video_port: + + # Pause video splitter port 1 + self.pause_stream_for_capture() + + self.camera.capture( + target, + format=fmt, + quality=100, + resize=resize, + bayer=True) + + # Resume video splitter port 1 + self.resume_stream_for_capture() + + else: + self.camera.capture( + target, + format=fmt, + quality=100, + resize=resize, + bayer=False, + use_video_port=True) + + # Update state dictionary + self.state['image_recent'] = str(target_obj) + + 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. + + use_video_port (bool): Capture from the video port used for streaming (lower resolution, faster) + resize ((int, int)): Resize the captured image. + """ + self.start_worker() + + if use_video_port: + resolution = self.settings['video_resolution'] + else: + resolution = self.settings['numpy_resolution'] + + if resize: + size = resize + else: + size = resolution + + if not use_video_port: + self.pause_stream_for_capture(resolution=resolution) + + log("Creating PiYUVArray") + with picamera.array.PiYUVArray(self.camera, size=size) as output: + log("Capturing to PiYUVArray from {}".format(self.camera)) + + self.camera.capture( + output, + resize=size, + format='yuv', + use_video_port=use_video_port) + + log("Capturing complete") + + if not use_video_port: + self.resume_stream_for_capture() + + if rgb: + log("Converting to RGB") + return output.rgb_array + else: + return output.array + + def frames(self): + """ + Create iterator used by worker thread to generate stream frames. + + Holds a Pi camera in context, records video from port 1 to a + byte stream, and iterates sequential frames. + """ + # Run this initialisation method + self.initialisation() + self.stream_method = 'PiCamera' + + with picamera.PiCamera() as self.camera: + # Let camera warm up + time.sleep(0.1) + # Settings config + self.update_settings() + # Set stream resolution + self.camera.resolution = self.settings['video_resolution'] + + # streaming + self.stream = io.BytesIO() + + # start recording on video splitter port 1 + self.camera.start_recording( + self.stream, + format='mjpeg', + quality=self.settings['jpeg_quality'], + splitter_port=1) + + while True: + # reset stream for next frame + self.stream.seek(0) + self.stream.truncate() + # to stream, read the new frame + time.sleep(1/self.settings['framerate']*0.1) + # yield the result to be read + frame = self.stream.getvalue() + # ensure the size of package is right + if len(frame) == 0: + pass + else: + yield frame diff --git a/openflexure_microscope/camera/set_picamera_gain.py b/openflexure_microscope/camera/set_picamera_gain.py new file mode 100644 index 00000000..a6eccc35 --- /dev/null +++ b/openflexure_microscope/camera/set_picamera_gain.py @@ -0,0 +1,70 @@ +from __future__ import print_function + +import picamera +from picamera import mmal, mmalobj, exc +from picamera.mmalobj import to_rational +import time + + +MMAL_PARAMETER_ANALOG_GAIN = mmal.MMAL_PARAMETER_GROUP_CAMERA + 0x59 +MMAL_PARAMETER_DIGITAL_GAIN = mmal.MMAL_PARAMETER_GROUP_CAMERA + 0x5A + +def set_gain(camera, gain, value): + """Set the analog gain of a PiCamera. + + camera: the picamera.PiCamera() instance you are configuring + gain: either MMAL_PARAMETER_ANALOG_GAIN or MMAL_PARAMETER_DIGITAL_GAIN + value: a numeric value that can be converted to a rational number. + """ + if gain not in [MMAL_PARAMETER_ANALOG_GAIN, MMAL_PARAMETER_DIGITAL_GAIN]: + raise ValueError("The gain parameter was not valid") + ret = mmal.mmal_port_parameter_set_rational(camera._camera.control._port, + gain, + to_rational(value)) + if ret == 4: + raise exc.PiCameraMMALError(ret, "Are you running the latest version of the userland libraries? Gain setting was introduced in late 2017.") + elif ret != 0: + raise exc.PiCameraMMALError(ret) + +def set_analog_gain(camera, value): + """Set the gain of a PiCamera object to a given value.""" + set_gain(camera, MMAL_PARAMETER_ANALOG_GAIN, value) + +def set_digital_gain(camera, value): + """Set the digital gain of a PiCamera object to a given value.""" + set_gain(camera, MMAL_PARAMETER_DIGITAL_GAIN, value) + + +if __name__ == "__main__": + with picamera.PiCamera() as cam: + cam.start_preview(fullscreen=False, window=(0,50,640,480)) + time.sleep(2) + + # fix the auto white balance gains at their current values + g = cam.awb_gains + cam.awb_mode = "off" + cam.awb_gains = g + + # fix the shutter speed + cam.shutter_speed = cam.exposure_speed + + print("Current a/d gains: {}, {}".format(cam.analog_gain, cam.digital_gain)) + + print("Attempting to set analogue gain to 1") + set_analog_gain(cam, 1) + print("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: + print("Current a/d gains: {}, {}".format(cam.analog_gain, cam.digital_gain)) + time.sleep(1) + except KeyboardInterrupt: + print("Stopping...") + + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 00000000..627ff384 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,10 @@ +click==6.7 +flask >= 0.12.3 +itsdangerous==0.24 +Jinja2==2.9.6 +MarkupSafe==1.0 +picamera==1.13 +pyyaml==3.13 +numpy==1.10.1 +Pillow +RPi.GPIO \ No newline at end of file diff --git a/setup.py b/setup.py new file mode 100644 index 00000000..2410c13b --- /dev/null +++ b/setup.py @@ -0,0 +1,56 @@ +from setuptools import setup, find_packages +from codecs import open +from os import path +import re + +__author__ = 'Joel Collins' + +HERE = path.abspath(path.dirname(__file__)) + +# Get the long description from the README file +with open(path.join(HERE, 'README.md'), encoding='utf-8') as f: + LONG_DESCRIPTION = f.read() + + +def find_version(): + """Determine the version based on __init__.py.""" + with open(path.join(HERE, "openflexure_microscope", "__init__.py"), 'r') as f: + init_py = f.read() + version_match = re.search(r"^__version__ = ['\"]([^'\"]*)['\"]", init_py, re.M) + if version_match: + return version_match.group(1) + raise RuntimeError("Couldn't parse version string from __init__.py") + +VERSION = find_version() + +setup( + name='openflexure_microscope', + version=VERSION, + description='Control scripts for the OpenFlexure Microscope', + long_description=LONG_DESCRIPTION, + author='Joel Collins', + author_email='joel@jtcollins.net', + packages=find_packages(), + keywords=['arduino', 'python', 'serial', 'microscope'], + zip_safe=True, + classifiers=[ + 'Development Status :: 3 - Alpha', + 'License :: OSI Approved :: GNU General Public License v3 (GPLv3)', + 'Programming Language :: Python :: 3.5' + ], + install_requires=[ + 'pyserial', + 'future', + 'openflexure_stage', + 'flask', + 'itsdangerous', + 'Jinja2', + 'MarkupSafe', + 'picamera', + 'Werkzeug', + 'pyyaml', + 'numpy', + 'Pillow', + 'RPi.GPIO', + ], +) diff --git a/start_interface b/start_interface new file mode 100644 index 00000000..b4ccb02b --- /dev/null +++ b/start_interface @@ -0,0 +1 @@ +gunicorn --threads 5 --workers 1 --bind 0.0.0.0:5000 openflexure_microscope.api.app:app \ No newline at end of file diff --git a/tests/test_camera.py b/tests/test_camera.py new file mode 100644 index 00000000..84363d32 --- /dev/null +++ b/tests/test_camera.py @@ -0,0 +1,337 @@ +#!/usr/bin/env python +from openflexure_microscope.camera.pi import StreamingCamera, StreamObject, log +import os +import io +import sys +import time +import numpy as np +import uuid + +from PIL import Image + +import unittest + +success_string = """ + /O + | | + _____) \\ + (__0) \\______ + (____0) + (____0) EVERYTHING IS OK! + (__o)___________ + """ + + +def wait_for_cam(camera, timeout=5): + """Wait for camera object in global namespace, with 5 second timeout.""" + timeout_time = time.time() + timeout + while not camera: + if time.time() > timeout_time: + raise TimeoutError("Timeout waiting for camera") + else: + pass + + +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)]: + # Create unique ID + id = uuid.uuid4().hex + + log("Resize: {}, V_Port: {}, ID: {}".format( + resize, + use_video_port, + id)) + + # Wait for camera + wait_for_cam(camera) + + # Capture to a context (auto-deletes files when done) + with camera.capture( + write_to_file=False, + use_video_port=use_video_port, + filename=id, + resize=resize) as stream: + + # Ensure file deletion fails and returns False + self.assertFalse(stream.delete()) + # Ensure capture not stored to file + self.assertFalse(os.path.isfile(stream.file)) + + # BEFORE DELETE: Ensure StreamObject 'stream' has + # a valid BytesIO object and byte string + self.assertTrue(isinstance( + stream.data, + io.IOBase + )) + self.assertTrue(isinstance( + stream.binary, + (bytes, bytearray) + )) + + # Save capture to file + stream.save() + # Check file got saved + self.assertTrue(os.path.isfile(stream.file)) + + # Delete file + stream.delete() + # Check file got deleted + self.assertFalse(os.path.isfile(stream.file)) + + # AFTER DELETE: Ensure StreamObject 'stream' has + # a valid BytesIO object and byte string + self.assertTrue(isinstance(stream.data, io.IOBase)) + self.assertTrue(isinstance( + stream.binary, + (bytes, bytearray) + )) + + # Create a PIL image from stream + image = Image.open(stream.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.settings['video_resolution'] + else: + dims = camera.settings['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)]: + # Create unique ID + id = uuid.uuid4().hex + + # Wait for camera + wait_for_cam(camera) + + # Capture + stream = camera.capture( + write_to_file=True, + use_video_port=use_video_port, + filename=id, + resize=resize) + + # Check file got saved + self.assertTrue(os.path.isfile(stream.file)) + statinfo = os.stat(stream.file) + self.assertTrue(statinfo.st_size > 0) + + # Ensure StreamObject 'stream' has + # a valid BytesIO object and byte string + self.assertTrue(isinstance(stream.data, io.IOBase)) + self.assertTrue(isinstance(stream.binary, (bytes, bytearray))) + + # Ensure file deletion completes and returns True + self.assertTrue(stream.delete()) + + # Check file got deleted + self.assertFalse(os.path.isfile(stream.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)]: + # Create unique ID + id = uuid.uuid4().hex + + print("{}, {}, {}".format(id, resize, use_video_port)) + + # Wait for camera + wait_for_cam(camera) + + # Capture RGB array + yuv = camera.array( + 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.settings['video_resolution'] + else: + dims = camera.settings['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)]: + # Create unique ID + id = uuid.uuid4().hex + + print("{}, {}, {}".format(id, resize, use_video_port)) + + # Wait for camera + wait_for_cam(camera) + + # Capture RGB array + rgb = camera.array( + rgb=True, + 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.settings['video_resolution'] + else: + dims = camera.settings['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 + + for write_to_file in [True, False, None]: + + # Create unique ID + id = uuid.uuid4().hex + + # Wait for camera + wait_for_cam(camera) + + # Start recording + with camera.start_recording( + write_to_file=write_to_file, + filename=id) as stream: + + # Record for 2 seconds + time.sleep(2) + # Stop recording + camera.stop_recording() + + # Check stream + self.assertTrue(isinstance(stream.data, io.IOBase)) + self.assertTrue(isinstance(stream.binary, (bytes, bytearray))) + + # Check file + if write_to_file: + statinfo = os.stat(stream.file) + self.assertTrue(statinfo.st_size > 0) + + # Log path + temp_path = stream.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 + + # Create unique ID + id = uuid.uuid4().hex + + # Wait for camera + wait_for_cam(camera) + + # Start recording + stream = camera.start_recording(filename=id) + # Record for 2 seconds + time.sleep(2) + # Stop recording + camera.stop_recording() + + # Check file + statinfo = os.stat(stream.file) + self.assertTrue(statinfo.st_size > 0) + + # Ensure file deletion completes and returns True + self.assertTrue(stream.delete()) + + # Check file got deleted + self.assertFalse(os.path.isfile(stream.file)) + + +class TestThreadStarting(unittest.TestCase): + def test_capture_restarting_stream(self): + """Tests that a capture call restarts the camera worker thread.""" + global camera + + # Create unique ID + id = uuid.uuid4().hex + + # Wait for camera + wait_for_cam(camera) + + # Force-stop the camera worker thread (should return True) + self.assertTrue(camera.stop_worker()) + + # Check Pi camera has been disconnected + self.assertFalse(camera.camera) + + # Restart worker thread by initialising a capture + with camera.capture( + use_video_port=True, + filename=id) as stream: + + # Ensure StreamObject 'stream' has + # a valid BytesIO object and byte string + self.assertTrue(isinstance(stream.data, io.IOBase)) + self.assertTrue(isinstance(stream.binary, (bytes, bytearray))) + + +if __name__ == '__main__': + camera = None + with StreamingCamera() 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) + + if result.wasSuccessful(): + print(success_string) + + camera.close()