Merge branch 'optimised-capture-threadpools' into 'master'
Optimised capture IO See merge request openflexure/openflexure-microscope-server!62
This commit is contained in:
commit
aa9f621476
12 changed files with 220 additions and 175 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue