diff --git a/MAPLE.cfg b/MAPLE.cfg index 92f2aba..6c29c9f 100644 --- a/MAPLE.cfg +++ b/MAPLE.cfg @@ -21,3 +21,8 @@ Z2OffsetY = 7 Z2OffsetZ = 10.7 +# To implement support for your own camera, follow examples in cameras.py. + +# Uncomment to use OpenCV supported camera. +# camera_class = cameras.OpenCVCamera + diff --git a/cameras.py b/cameras.py new file mode 100644 index 0000000..88ceeef --- /dev/null +++ b/cameras.py @@ -0,0 +1,198 @@ +## +## This copyrighted software is distributed under the GPL v2.0 license. +## See the LICENSE file for more details. +## + +""" +Abstraction layer for other camera interfaces (pyicic, opencv, etc) + +To implement a custom camera, subclass cameras.Camera, and implement the +methods with the @abstractmethod decorator. +""" + +import abc +from abc import abstractmethod +import warnings + +import numpy as np +import cv2 + + +class CameraNotFoundError(IOError): pass +class NoFrameError(IOError): pass + +# Should work across Python 2 and 3. +# see: https://gist.github.com/alanjcastonguay/25e4db0edd3534ab732d6ff615ca9fc1 +ABC = abc.ABCMeta('ABC', (object,), {}) + +class Camera(ABC): + @abstractmethod + def __init__(self): + """ + Camera needs an __init__ with no arguments, so it can be instantiated + in a uniform way inside of the MAPLE __init__. + + Any dependencies specific to your camera should be imported as the first + lines inside this function. + + Your __init__ implementation will likely create some other Python object + to manage interactions with the camera through whatever other library. + If this object is called `backing_camera_object`, the last line of your + __init__ function should be: + ``` + self.cam = backing_camera_object + ``` + + Then, to change other properties of the camera after __init__, you can + access the camera of the MAPLE robot like so: + ``` + robot = robotutil.MAPLE('MAPLE.cfg') + camera_wrapper = robot.cam + backing_camera_object = camera_wrapper.cam + ``` + """ + pass + + @abstractmethod + def get_frame(self): + """Returns a current frame from the camera. + + Returned frame must be a numpy.ndarray of dimensions + (height, width, color), where the colorspace is BGR. + + Raises NoFrameError if could not get a frame from the camera. + """ + pass + + @abstractmethod + def close(self): + """Releases resources associated with connection to this camera. + """ + pass + + def write_png(self, filename): + """Write current frame to filename in PNG format. + + Args: + filename (str): Path to save to. + + Raises NoFrameError if could not get a frame from the camera. + """ + frame = self.get_frame() + cv2.imwrite(filename, frame) + + def write_jpg(self, filename, quality=95): + """Write current frame to filename in JPG format, with optional quality. + + Args: + filename (str): Path to save to. + quality (int): (optional) 0-100 jpg quality (100=highest quality) + + Raises NoFrameError if could not get a frame from the camera. + """ + frame = self.get_frame() + IMWRITE_JPEG_QUALITY = 1 + cv2.imwrite(filename, frame, [IMWRITE_JPEG_QUALITY, quality]) + + +class PyICIC_Camera(Camera): + def __init__(self): + import pyicic.IC_ImagingControl + + ic_ic = pyicic.IC_ImagingControl.IC_ImagingControl() + ic_ic.init_library() + cam_names = ic_ic.get_unique_device_names() + + if len(cam_names) == 0: + raise CameraNotFoundError('pyicic camera not found.') + + cam = ic_ic.get_device(cam_names[0]) + cam.open() + + cam.gain.value = 10 + cam.exposure.auto = False + # TODO does a negative exposure make sense? + cam.exposure.value = -10 + cam.set_video_format('BY8 (2592x1944)') + cam.set_frame_rate(4.00) + + cam.prepare_live() + + self.cam = cam + + def get_frame(self): + """Returns a current frame from the camera. + """ + # TODO is there much overhead w/ start_live / stop_live calls? + # mechanism to keep live? + self.cam.start_live() + self.cam.snap_image() + imgdata, w, h, d = self.cam.get_image_data() + self.cam.stop_live() + return np.ndarray(buffer=imgdata, dtype=np.uint8, shape=(h, w, d)) + + def write_jpg(self, filename, quality=50): + """Writes a current image to filename in jpg format. + """ + robot.cam.start_live() + robot.cam.snap_image() + # the 1 is for JPG, 0 is for BMP + robot.cam.save_image(filename, 1, jpeq_quality=qualPic) + robot.cam.stop_live() + + def close(self): + """Releases resources associated with connection to this camera. + """ + self.cam.close() + + +class OpenCVCamera(Camera): + def __init__(self): + if hasattr(cv2, 'cv'): + self.cv2_v3 = False + else: + self.cv2_v3 = True + + cam = cv2.VideoCapture(0) + if not cam.isOpened(): + raise CameraNotFoundError('OpenCV compatible camera not found.') + + self.cam = cam + + def get_frame(self): + """Returns a current frame from the camera. + + Raises NoFrameError if could not get a frame from the camera. + """ + # TODO detect and give meaningful error message for that select timeout + # err? (having trouble reproducing now... camera wasn't closed + # properly?) + success, data = self.cam.read() + if not success: + raise NoFrameError('OpenCV VideoCapture.read() failed.') + else: + return data + + def close(self): + """Releases resources associated with connection to this camera. + """ + self.cam.release() + + def cap_prop_id(self, name): + """Returns cv2 integer code for property with name.""" + # TODO also handle case where property doesn't exist + return getattr(cv2 if self.cv2_v3 else cv2.cv, + ('' if self.cv2_v3 else 'CV_') + 'CAP_PROP_' + name) + + def set_property(self, vc, name, value): + """Sets cv2.VideoCapture property. Warns if can not.""" + print 'trying to set {} to {}'.format(name, value) + property_code = self.cap_prop_id(name) + v = vc.get(property_code) + if v == -1: + warnings.warn('Could not set property {}'.format(name)) + else: + vc.set(property_code, value) + + # TODO provide method to list properties camera *does* seem to support + diff --git a/remoteOperation.py b/remoteOperation.py index 1e795d5..5ffa83d 100644 --- a/remoteOperation.py +++ b/remoteOperation.py @@ -41,7 +41,7 @@ def notifyUserFail(robot, arenanum, mailfrom, attPic=0, qualPic=25, attImg=1, de msg = MIMEMultipart() for imgnum in range(len(arenanum)): curarenanum = str(arenanum[imgnum]) - imgname = curarenanum + 'errImage.png' + imgname = curarenanum + 'errImage.jpg' with open(imgname, 'rb') as fp: img = MIMEImage(fp.read()) msg.attach(img) @@ -55,21 +55,18 @@ def notifyUserFail(robot, arenanum, mailfrom, attPic=0, qualPic=25, attImg=1, de arenanum = str(arenanum) robot.light(True) time.sleep(0.2) - robot.cam.start_live() - robot.cam.snap_image() - robot.cam.save_image(arenanum + 'errImage.png', 1, jpeq_quality=qualPic) - robot.cam.stop_live() + robot.write_jpg(arenanum + 'errImage.jpg', 1, quality=qualPic) robot.dwell(50) robot.light(False) msg['Subject'] = 'Failure: Arena ' + arenanum + ' Withdraw' msg = MIMEMultipart() msg.preamble = 'Arena ' + arenanum + ' failed to unload.' - fp = open(arenanum + 'errImage.png', 'rb') + fp = open(arenanum + 'errImage.jpg', 'rb') img = MIMEImage(fp.read()) fp.close() msg.attach(img) if delFiles == 1: - os.remove(arenanum + 'errImage.png') + os.remove(arenanum + 'errImage.jpg') if attPic == 0 and attImg == 0: arenanum = str(arenanum) msg['Subject'] = 'Failure: Arena ' + arenanum + ' Withdraw' diff --git a/robotutil.py b/robotutil.py index 351c6d1..be622b6 100644 --- a/robotutil.py +++ b/robotutil.py @@ -11,14 +11,17 @@ ## Dependencies import os import math -import cv2 -import numpy as np import time import ConfigParser import urllib2 -import random as rand -import pyicic.IC_ImagingControl +import importlib +import pyclbr + +import numpy as np +import cv2 + import flysorterSerial +import cameras class MAPLE: """Class for fly manipulation robot.""" @@ -57,10 +60,11 @@ class MAPLE: 'Z2OffsetZ': '8', 'HFOV': '14.5', 'VFOV': '11.25', - 'StatusURL': '' + 'StatusURL': '', + 'camera_class': 'cameras.PyICIC_Camera' } - def __init__(self, robotConfigFile,initDisp=1): + def __init__(self, robotConfigFile, cam_class=None): print "Reading config file...", self.config = ConfigParser.RawConfigParser(self.configDefaults) self.readConfig(robotConfigFile) @@ -83,17 +87,51 @@ def __init__(self, robotConfigFile,initDisp=1): tempPort.close() if self.smoothie is None: - print "Serial initialization failed." - if self.smoothie is None: - print "Smoothie board not found." - return + raise IOError( + 'Serial initialization failed. Smoothie board not found.') + + def print_builtin_cameras(): + cams = pyclbr.readmodule('cameras') + cams.pop('Camera') + cams.pop('CameraNotFoundError') + cams.pop('NoFrameError') + print '\n\nAvailable cameras:' + for c in cams: + print 'cameras.{}'.format(c) + + def class2str(cls): + return '{}.{}'.format(cls.__module__, cls.__name__) print "Initializing camera...", - self.cam = cameraInit() - if self.cam == None: - print "Camera init fail." + if cam_class is None: + parts = self.full_cam_class_name.rsplit('.', 1) + try: + cam_module = importlib.import_module(parts[0]) + cam_class = getattr(cam_module, parts[1]) + + except (ImportError, AttributeError) as e: + print_builtin_cameras() + print ('\nSet camera_class to one of them in your ' + + 'MAPLE.cfg file.\n') + self.smoothie.close() + raise + + if not issubclass(cam_class, cameras.Camera): + print_builtin_cameras() self.smoothie.close() - return + raise ValueError('camera_class must subclass cameras.Camera' + + ', but class {} does not.'.format(class2str(cam_class))) + + try: + self.cam = cam_class() + except ImportError as e: + print_builtin_cameras() + print ('\nSet camera_class to one of them in your ' + + 'MAPLE.cfg file.\n') + print 'Missing dependencies for camera_class={}'.format( + class2str(cam_class)) + self.smoothie.close() + raise print "done." @@ -119,7 +157,9 @@ def readConfig(self, configFile): self.StatusURL = self.config.get('DEFAULT', 'StatusURL') if ( self.StatusURL != ''): urllib2.urlopen(StatusURL + "&" + "st=1") - return + + self.full_cam_class_name = self.config.get('DEFAULT', 'camera_class') + def release(self): self.light(False) @@ -147,14 +187,7 @@ def homeZ0(self): # Captures current camera image; returns as numpy array def captureImage(self): - self.cam.start_live() - self.cam.snap_image() - (imgdata, w, h, d) = self.cam.get_image_data() - self.cam.stop_live() - img = np.ndarray(buffer = imgdata, - dtype = np.uint8, - shape = (h, w, d)) - return img + return self.cam.get_frame() # Returns current position as array def getCurrentPosition(self): @@ -463,17 +496,14 @@ def smallPartManipVac(self, onOff = False): # rerouted to fly vacuum return # Captures arena picture at location (Images named consecutively if multiple coordinates specified) - def SavePicAt(self, Xcoords, Ycoords, IndVect, qualPic=25, Zcam=40, ImgName='errImage.png'): + def SavePicAt(self, Xcoords, Ycoords, IndVect, qualPic=25, Zcam=40, ImgName='errImage.jpg'): self.light(True) - self.cam.start_live() for ImgNum in range(len(Xcoords)): self.moveToSpd(pt=[float(Xcoords[ImgNum]), float(Ycoords[ImgNum]), 0, Zcam, 10, 5000]) self.dwell(50) # Put higher to reduce effect of motion-caused rig trembling on picture - self.cam.snap_image() curInd = str(IndVect[ImgNum]) - self.cam.save_image(curInd + 'errImage.png', 1, jpeq_quality=qualPic) + self.cam.write_jpg(curInd + 'errImage.jpg', quality=qualPic) self.dwell(10) - self.cam.stop_live() self.light(False) # Finds immobile fly on white surface (CO2 board) @@ -651,20 +681,3 @@ def findDegs(self, slowmode=True, precision=4, MAX_SIZE=74, MIN_SIZE=63, startp1 tempdeg = self.getDegs(circles) return tempdeg - -# Set up close-up camera -def cameraInit(): - global ic_ic - print "Opening camera interface." - ic_ic = pyicic.IC_ImagingControl.IC_ImagingControl() - ic_ic.init_library() - cam_names = ic_ic.get_unique_device_names() - cam = ic_ic.get_device(cam_names[0]) - cam.open() - cam.gain.value = 10 - cam.exposure.auto = False - cam.exposure.value = -10 - cam.set_video_format('BY8 (2592x1944)') - cam.set_frame_rate(4.00) - cam.prepare_live() - return cam