Merge branch 'optimised-capture-threadpools' into 'master'

Optimised capture IO

See merge request openflexure/openflexure-microscope-server!62
This commit is contained in:
Joel Collins 2020-06-15 16:28:25 +00:00
commit aa9f621476
12 changed files with 220 additions and 175 deletions

View file

@ -3,13 +3,20 @@ from gevent import monkey
monkey.patch_all()
import sys
import time
import atexit
import logging, logging.handlers
# Look for debug flag
if "-d" in sys.argv or "--debug" in sys.argv:
log_level = logging.DEBUG
else:
log_level = logging.INFO
# Set root logger level
logger = logging.getLogger()
logger.setLevel(logging.INFO)
logger.setLevel(log_level)
import os
import pkg_resources

View file

@ -25,7 +25,7 @@ class MjpegStream(PropertyView):
frame from the camera object, passed to the Flask response, and then repeats until
the connection is closed.
Without monkey patching with gevent, or using a native threaded server, the stream
Without monkey patching, or using a native threaded server, the stream
will block all proceeding requests.
"""
microscope = find_component("org.openflexure.microscope")

View file

@ -2,12 +2,10 @@
import time
import os
import shutil
import threading
import datetime
import logging
import threading
import gevent
from abc import ABCMeta, abstractmethod
@ -25,7 +23,7 @@ class BaseCamera(metaclass=ABCMeta):
self.thread = None
self.camera = None
self.lock = StrictLock(timeout=1, name="Camera")
self.lock = StrictLock(name="Camera")
self.frame = None
self.last_access = 0
@ -93,9 +91,10 @@ class BaseCamera(metaclass=ABCMeta):
self.stop = False
if not self.stream_active:
# Spawn a greenlet to handle stream
# start background frame thread
self.thread = gevent.spawn(self._thread)
# Start background frame thread
self.thread = threading.Thread(target=self._thread)
self.thread.daemon = True
self.thread.start()
# wait until frames are available
logging.info("Waiting for frames")

View file

@ -197,19 +197,14 @@ class MissingCamera(BaseCamera):
bayer (bool): Store raw bayer data in capture
"""
if isinstance(output, CaptureObject):
target = output.file
else:
target = output
with self.lock:
if isinstance(target, str):
target = open(target, "wb")
if isinstance(output, str):
output = open(output, "wb")
target.write(self.stream.getvalue())
output.write(self.stream.getvalue())
if isinstance(target, str):
target.close()
if isinstance(output, str):
output.close()
# HANDLE STREAM FRAMES

View file

@ -108,18 +108,14 @@ class PiCameraStreamer(BaseCamera):
1312,
976,
) #: tuple: Resolution for numpy array captures
self.jpeg_quality = 75 #: int: JPEG quality
self.jpeg_quality = 100 #: int: JPEG quality
self.mjpeg_quality = 75 #: int: MJPEG quality
# Set default lens shading table path
self.picamera_lst_path = settings_file_path(
"picamera_lst.npy"
) #: str: Path of .npy lens shading table file
# Create an empty stream
self.stream = io.BytesIO()
# Start streaming
self.start_worker()
@property
def configuration(self):
@ -159,6 +155,7 @@ class PiCameraStreamer(BaseCamera):
"image_resolution": self.image_resolution,
"numpy_resolution": self.numpy_resolution,
"jpeg_quality": self.jpeg_quality,
"mjpeg_quality": self.mjpeg_quality,
"picamera": {},
}
)
@ -479,7 +476,7 @@ class PiCameraStreamer(BaseCamera):
self.camera.start_recording(
self.stream,
format="mjpeg",
quality=self.jpeg_quality,
quality=self.mjpeg_quality,
bitrate=-1, # RWB: disable bitrate control
# (bitrate control makes JPEG size less good as a focus
# metric)
@ -520,12 +517,6 @@ class PiCameraStreamer(BaseCamera):
Returns:
output_object (str/BytesIO): Target object.
"""
if isinstance(output, CaptureObject):
target = output.file
else:
target = output
with self.lock:
logging.info("Capturing to {}".format(output))
@ -535,20 +526,20 @@ class PiCameraStreamer(BaseCamera):
time.sleep(0.1)
self.camera.capture(
target,
output,
format=fmt,
quality=100,
quality=self.jpeg_quality,
resize=resize,
bayer=(not use_video_port) and bayer,
use_video_port=use_video_port,
)
time.sleep(0.1)
#time.sleep(0.1)
# Set resolution and start stream recording if necessary
if not use_video_port:
self.start_stream_recording()
return target
return output
def yuv(
self, use_video_port: bool = True, resize: Tuple[int, int] = None

View file

@ -10,6 +10,9 @@ from PIL import Image
import dateutil.parser
import atexit
from gevent.fileobject import FileObjectThread
import gevent
from collections import OrderedDict
from openflexure_microscope.camera import piexif
@ -118,12 +121,18 @@ def capture_from_exif(path, exif_dict):
class CaptureObject(object):
"""
StreamObject used to store and process on-disk capture data, and metadata.
File-like object used to store and process on-disk capture data, and metadata.
Serves to simplify modifying properties of on-disk capture data.
"""
def __init__(self, filepath) -> None:
"""Create a new StreamObject, to manage capture data."""
# Stream for buffering capture data
self.stream = io.BytesIO()
# Event to notify when the stream has finished writing to disk
self.file_ready = gevent.event.Event()
# Access lock for the disk file
self.lock = gevent.lock.BoundedSemaphore()
# Store a nice ID
self.id = uuid.uuid4() #: str: Unique capture ID
@ -148,6 +157,23 @@ class CaptureObject(object):
# Thumbnail (populated only for PIL captures)
self.thumb_bytes = None
def write(self, s):
self.stream.write(s)
def _stream_to_file(self):
with self.lock:
logging.info(f"Writing to disk {self.file}")
with FileObjectThread(open(self.file, "wb"), 'wb') as outfile:
outfile.write(self.stream.getbuffer())
self.stream.close()
self.file_ready.set()
logging.info(f"Finished writing to disk {self.file}")
def flush(self):
logging.debug(f"Flushing {self.file}")
gevent.spawn(self._stream_to_file)
logging.debug(f"Returning flushing {self.file}")
def open(self, mode):
return open(self.file, mode)
@ -227,18 +253,20 @@ class CaptureObject(object):
global EXIF_FORMATS
if self.format.upper() in EXIF_FORMATS and self.exists:
logging.debug("Writing exif data to capture file")
# Extract current Exif data
exif_dict = piexif.load(self.file)
# Serialize metadata
metadata_string = json.dumps(self.metadata, cls=JSONEncoder)
logging.debug(f"Saving metadata string to file: {metadata_string}")
# Insert metadata into exif_dict
exif_dict["Exif"][piexif.ExifIFD.UserComment] = metadata_string.encode()
# Convert new exif dict to exif bytes
exif_bytes = piexif.dump(exif_dict)
# Insert exif into file
piexif.insert(exif_bytes, self.file)
self.file_ready.wait()
with self.lock:
logging.debug("Writing exif data to capture file")
# Extract current Exif data
exif_dict = piexif.load(self.file)
# Serialize metadata
metadata_string = json.dumps(self.metadata, cls=JSONEncoder)
logging.debug(f"Saving metadata string to file: {metadata_string}")
# Insert metadata into exif_dict
exif_dict["Exif"][piexif.ExifIFD.UserComment] = metadata_string.encode()
# Convert new exif dict to exif bytes
exif_bytes = piexif.dump(exif_dict)
# Insert exif into file
piexif.insert(exif_bytes, self.file)
@property
def metadata(self) -> dict:

View file

@ -7,6 +7,8 @@ import pkg_resources
import uuid
from typing import Tuple
import gevent
from openflexure_microscope.captures import CaptureManager
from openflexure_microscope.stage.mock import MissingStage
@ -151,39 +153,41 @@ class Microscope:
Return:
dict: Dictionary containing complete microscope state
"""
state = {"camera": self.camera.state, "stage": self.stage.state}
return state
with self.lock:
state = {"camera": self.camera.state, "stage": self.stage.state}
return state
def update_settings(self, settings: dict):
"""
Applies a settings dictionary to the microscope. Missing parameters will be left untouched.
"""
logging.debug("Microscope: Applying settings: {}".format(settings))
with self.lock:
logging.debug("Microscope: Applying settings: {}".format(settings))
# If attached to a camera
if ("camera" in settings) and self.camera:
self.camera.update_settings(settings.get("camera", {}))
# If attached to a camera
if ("camera" in settings) and self.camera:
self.camera.update_settings(settings.get("camera", {}))
# If attached to a stage
if ("stage" in settings) and self.stage:
self.stage.update_settings(settings.get("stage", {}))
# If attached to a stage
if ("stage" in settings) and self.stage:
self.stage.update_settings(settings.get("stage", {}))
# Capture manager
self.captures.update_settings(settings.get("captures", {}))
# Capture manager
self.captures.update_settings(settings.get("captures", {}))
# Microscope settings
if "id" in settings:
self.id = settings["id"]
if "name" in settings:
self.name = settings["name"]
if "fov" in settings:
self.fov = settings["fov"]
# Microscope settings
if "id" in settings:
self.id = settings["id"]
if "name" in settings:
self.name = settings["name"]
if "fov" in settings:
self.fov = settings["fov"]
# Extension settings
if "extensions" in settings:
self.extension_settings.update(settings["extensions"])
# Extension settings
if "extensions" in settings:
self.extension_settings.update(settings["extensions"])
# TODO: warn if there are settings that we silently ignore
# TODO: warn if there are settings that we silently ignore
def read_settings(self, full: bool = True):
"""
@ -195,49 +199,50 @@ class Microscope:
This is to ensure that settings for currently disconnected hardware
don't get removed from the settings file.
"""
with self.lock:
settings_current = {
"id": self.id,
"name": self.name,
"fov": self.fov,
"extensions": self.extension_settings,
}
settings_current = {
"id": self.id,
"name": self.name,
"fov": self.fov,
"extensions": self.extension_settings,
}
# If attached to a camera
if self.camera:
settings_current_camera = self.camera.read_settings()
settings_current["camera"] = settings_current_camera
# If attached to a camera
if self.camera:
settings_current_camera = self.camera.read_settings()
settings_current["camera"] = settings_current_camera
# Store an encoded copy of the PiCamera lens shading table, if it exists
if hasattr(self.camera, "read_lens_shading_table"):
# Read LST. Returns None if no LST is active
lst_arr = self.camera.read_lens_shading_table()
# Store an encoded copy of the PiCamera lens shading table, if it exists
if hasattr(self.camera, "read_lens_shading_table"):
# Read LST. Returns None if no LST is active
lst_arr = self.camera.read_lens_shading_table()
if lst_arr is not None:
b64_string, dtype, shape = serialise_array_b64(lst_arr)
if lst_arr is not None:
b64_string, dtype, shape = serialise_array_b64(lst_arr)
settings_current["camera"]["lens_shading_table"] = {
"@type": "ndarray",
"b64_string": b64_string,
"dtype": dtype,
"shape": shape,
}
settings_current["camera"]["lens_shading_table"] = {
"@type": "ndarray",
"b64_string": b64_string,
"dtype": dtype,
"shape": shape,
}
# If attached to a stage
if self.stage:
settings_current_stage = self.stage.read_settings()
settings_current["stage"] = settings_current_stage
# If attached to a stage
if self.stage:
settings_current_stage = self.stage.read_settings()
settings_current["stage"] = settings_current_stage
# Capture manager
settings_current_captures = self.captures.read_settings()
settings_current["captures"] = settings_current_captures
# Capture manager
settings_current_captures = self.captures.read_settings()
settings_current["captures"] = settings_current_captures
settings_full = self.settings_file.merge(settings_current)
settings_full = self.settings_file.merge(settings_current)
if full:
return settings_full
else:
return settings_current
if full:
return settings_full
else:
return settings_current
def save_settings(self):
"""
@ -286,6 +291,21 @@ class Microscope:
return system_metadata
def add_metadata_to_capture(self, output, metadata, annotations, tags):
logging.debug(f"Waiting for {output.file}")
# Wait for the file to be written to disk
output.file_ready.wait()
logging.info(f"Asynchronously injecting EXIF data into {output.file}")
# Inject system metadata
output.put_metadata({"instrument": self.metadata})
# Insert custom metadata
output.put_metadata(metadata)
# Insert custom metadata
output.put_annotations(annotations)
# Insert custom tags
output.put_tags(tags)
logging.info(f"Finished injecting EXIF data into {output.file}")
def capture(
self,
filename: str = None,
@ -299,6 +319,7 @@ class Microscope:
tags: list = None,
metadata: dict = None,
):
logging.debug(f"Microscope capturing to {filename}")
if not annotations:
annotations = {}
if not metadata:
@ -313,21 +334,18 @@ class Microscope:
)
# Capture to output object
logging.info("Starting microscope capture...")
self.camera.capture(
output.file,
output,
use_video_port=use_video_port,
resize=resize,
bayer=bayer,
fmt=fmt,
)
# Inject system metadata
output.put_metadata({"instrument": self.metadata})
# Insert custom metadata
output.put_metadata(metadata)
# Insert custom metadata
output.put_annotations(annotations)
# Insert custom tags
output.put_tags(tags)
# Gether metadata from hardware in a greenlet
gevent.get_hub().threadpool.spawn(self.add_metadata_to_capture, output, metadata, annotations, tags)
logging.info(f"Finished capture to {output.file}")
return output

View file

@ -11,7 +11,7 @@ class BaseStage(metaclass=ABCMeta):
"""
def __init__(self):
self.lock = StrictLock(timeout=5)
self.lock = StrictLock(name="Stage")
@abstractmethod
def update_settings(self, config: dict):

View file

@ -51,7 +51,8 @@ class SangaStage(BaseStage):
@property
def position(self):
return self.board.position
with self.lock:
return self.board.position
@property
def backlash(self):
@ -108,6 +109,7 @@ class SangaStage(BaseStage):
backlash: (default: True) whether to correct for backlash.
"""
with self.lock:
logging.info(f"Moving sangaboard by {displacement}")
if not backlash or self.backlash is None:
return self.board.move_rel(displacement, axis=axis)
@ -145,6 +147,7 @@ class SangaStage(BaseStage):
"""Make an absolute move to a position
"""
with self.lock:
logging.info(f"Moving sangaboard to {final}")
self.board.move_abs(final, **kwargs)
def zero_position(self):