diff --git a/openflexure_microscope/camera/pi.py b/openflexure_microscope/camera/pi.py index 5199b73f..f327309c 100644 --- a/openflexure_microscope/camera/pi.py +++ b/openflexure_microscope/camera/pi.py @@ -34,6 +34,9 @@ import numpy as np from PIL import Image import logging +# Used for conversion only +from fractions import Fraction + # Pi camera import picamera import picamera.array @@ -54,14 +57,29 @@ PICAMERA_KEYS = [ 'awb_gains', 'framerate', 'shutter_speed', - 'saturation', - 'led', ] + 'saturation', ] CONFIG_KEYS = [ 'video_resolution', 'image_resolution', 'numpy_resolution', - 'jpeg_quality', ] + 'jpeg_quality', + 'analog_gain', + 'digital_gain', + 'shading_table_path'] + + +def fractions_to_floats(value): + """Deal with horrible, horrible PiCamera fractions""" + result = value + if type(value) is list or type(value) is tuple: + result = [float(v) if isinstance(v, Fraction) else v for v in value] + if type(value) is tuple: + result = tuple(result) + else: + if isinstance(value, Fraction): + result = float(value) + return result class StreamingCamera(BaseCamera): @@ -124,7 +142,7 @@ class StreamingCamera(BaseCamera): """ return hasattr(self.camera, "lens_shading_table") - def update_lens_shading(self, shading_array: np.ndarray): + def apply_shading_table(self): if not self.supports_lens_shading: logging.error("the currently-installed picamera library does not support all " "the features of the openflexure microscope software. These features " @@ -132,8 +150,12 @@ class StreamingCamera(BaseCamera): "\n" "See the installation instructions for how to fix this:\n" "https://github.com/rwb27/openflexure_microscope_software") + elif 'shading_table_path' not in self.config: + logging.warning("No shading table path given in config.") else: - logging.debug("Lens shading is supported! HOORAY!") + logging.debug("Applying shading table from {}".format(self.config['shading_table_path'])) + npy = np.load(self.config['shading_table_path']) + self.camera.lens_shading_table = npy @property def config(self): @@ -149,13 +171,16 @@ class StreamingCamera(BaseCamera): # PiCamera parameters (obtained directly from PiCamera object) for key in PICAMERA_KEYS: try: - conf_dict['picamera_params'][key] = getattr(self.camera, key) + value = getattr(self.camera, key) + value = fractions_to_floats(value) + conf_dict['picamera_params'][key] = value except AttributeError: logging.warning("Unable to read PiCamera attribute {}".format(key)) - # StreamingCamera parameters (obtained from StreamingCamera __config) + # StreamingCamera parameters (obtained from StreamingCamera _config) for key in CONFIG_KEYS: - conf_dict[key] = self._config[key] + if key in self._config: + conf_dict[key] = self._config[key] return conf_dict @@ -186,9 +211,6 @@ class StreamingCamera(BaseCamera): self.pause_stream_for_capture() # Pause stream paused_stream = True # Remember to unpause stream when done - # Handle lens shading - self.update_lens_shading([]) - # PiCamera parameters (applied directly to PiCamera object) if 'picamera_params' in config: for key in PICAMERA_KEYS: @@ -208,7 +230,12 @@ class StreamingCamera(BaseCamera): if 'digital_gain' in config: set_digital_gain(self.camera, config['digital_gain']) - if paused_stream: # If stream was paused to update config + # Handle lens shading, if a new one is given + if 'shading_table_path' in config: + self.apply_shading_table() + + # If stream was paused to update config, unpause + if paused_stream: logging.info("Resuming stream.") self.resume_stream_for_capture() diff --git a/openflexure_microscope/camera/set_picamera_gain.py b/openflexure_microscope/camera/set_picamera_gain.py index a6eccc35..2064df4b 100644 --- a/openflexure_microscope/camera/set_picamera_gain.py +++ b/openflexure_microscope/camera/set_picamera_gain.py @@ -9,9 +9,10 @@ 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. @@ -26,10 +27,12 @@ def set_gain(camera, gain, value): 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) diff --git a/openflexure_microscope/config.py b/openflexure_microscope/config.py index 809d261b..be2c7beb 100644 --- a/openflexure_microscope/config.py +++ b/openflexure_microscope/config.py @@ -55,7 +55,7 @@ def load_config(config_path: str=None) -> dict: return convert_config(config_data) -def save_config(self, config_dict: dict, config_path: str=None): +def save_config(config_dict: dict, config_path: str=None, safe: bool=False): """ Save current config dictionary to a YAML file. @@ -68,4 +68,31 @@ def save_config(self, config_dict: dict, config_path: str=None): config_path = USER_CONFIG_PATH with open(config_path, 'w') as outfile: - yaml.dump(config_dict, outfile) + if not safe: + yaml.dump(config_dict, outfile) + else: + yaml.safe_dump(config_dict, outfile) + + +def merge_config(config_dict: dict, config_path: str=None, safe: bool=False, backup: bool=True): + """ + merge current config dictionary with an existing YAML file. + + Args: + config_dict (dict): Dictionary of config data to save. + config_path (str): Path to the config YAML file. If `None`, defaults to `DEFAULT_CONFIG_PATH` + """ + global USER_CONFIG_PATH + if not config_path: + config_path = USER_CONFIG_PATH + + config_data = load_config(config_path=config_path) + + for key, value in config_dict.items(): + config_data[key] = value + + if backup: + if os.path.isfile(config_path): + shutil.copyfile(config_path, config_path+".bk") + + save_config(config_data, config_path=config_path, safe=safe) diff --git a/openflexure_microscope/utilities/recalibrate.py b/openflexure_microscope/utilities/recalibrate.py new file mode 100644 index 00000000..acdede53 --- /dev/null +++ b/openflexure_microscope/utilities/recalibrate.py @@ -0,0 +1,163 @@ +from __future__ import print_function + +from openflexure_microscope.camera.pi import StreamingCamera +from openflexure_microscope import config + +import numpy as np +import sys +import time +import matplotlib.pyplot as plt +import os + +import logging, sys +logging.basicConfig(stream=sys.stderr, level=logging.DEBUG) + +def lens_shading_correction_from_rgb(rgb_array, binsize=64): + """Calculate a correction to a lens shading table from an RGB image. + + Returns: + a floating-point table of gains that should multiply the current + lens shading table. + """ + full_resolution = rgb_array.shape[:2] + table_resolution = [(r // binsize) + 1 for r in full_resolution] + lens_shading = np.zeros([4] + table_resolution, dtype=np.float) + + for i in range(3): + # We simplify life by dealing with only one channel at a time. + image_channel = rgb_array[:,:,i] + iw, ih = image_channel.shape + ls_channel = lens_shading[int(i*1.6),:,:] # NB there are *two* green channels + lw, lh = ls_channel.shape + # The lens shading table is rounded **up** in size to 1/64th of the size of + # the image. Rather than handle edge images separately, I'm just going to + # pad the image by copying edge pixels, so that it is exactly 32 times the + # size of the lens shading table (NB 32 not 64 because each channel is only + # half the size of the full image - remember the Bayer pattern... This + # should give results very close to 6by9's solution, albeit considerably + # less computationally efficient! + padded_image_channel = np.pad(image_channel, + [(0, lw*binsize - iw), (0, lh*binsize - ih)], + mode="edge") # Pad image to the right and bottom + assert padded_image_channel.shape == (lw*binsize, lh*binsize), "padding problem" + # Next, fill the shading table (except edge pixels). Please excuse the + # for loop - I know it's not fast but this code needn't be! + box = 3 # We average together a square of this side length for each pixel. + # NB this isn't quite what 6by9's program does - it averages 3 pixels + # horizontally, but not vertically. + for dx in np.arange(box) - box//2: + for dy in np.arange(box) - box//2: + ls_channel[:,:] += padded_image_channel[binsize//2+dx::binsize,binsize//2+dy::binsize] + ls_channel /= box**2 + # Everything is normalised relative to the centre value. I follow 6by9's + # example and average the central 64 pixels in each channel. + channel_centre = np.mean(image_channel[iw//2-4:iw//2+4, ih//2-4:ih//2+4]) + print("channel {} centre brightness {}".format(i, channel_centre)) + ls_channel /= channel_centre + # NB the central pixel should now be *approximately* 1.0 (may not be exactly + # due to different averaging widths between the normalisation & shading table) + # For most sensible lenses I'd expect that 1.0 is the maximum value. + # NB ls_channel should be a "view" of the whole lens shading array, so we don't + # need to update the big array here. + print("min {}, max {}".format(ls_channel.min(), ls_channel.max())) + # What we actually want to calculate is the gains needed to compensate for the + # lens shading - that's 1/lens_shading_table_float as we currently have it. + lens_shading[2, ...] = lens_shading[1, ...] # Duplicate the green channels + gains = 1.0/lens_shading # 32 is unity gain + return gains + +def gains_to_lst(gains): + """Given a lens shading gains table (where no gain=1.0), convert to 8-bit.""" + lst = gains / np.min(gains)*32 # minimum gain is 32 (= unity gain) + lst[lst > 255] = 255 # clip at 255 + return lst.astype(np.uint8) + +def generate_lens_shading_table_closed_loop(output_fname="shadingtable.npy", + n_iterations=5, + images_to_average=5): + """Reset the camera's parameters, and recalibrate the lens shading to get unifrom images. + + This function requires the microscope to be set up with a blank, uniformly + illuminated field of view. When it runs, it first auto-exposes, then fixes + the gains/shutter speed and resets the lens shading correction to a unity + gain. Rather than take a single raw image and calibrate from that (as + done in the open loop version, which is a more or less direct Python port + of 6by9's C code), we do it incrementally. Each iteration (of a default 5) + consists of acquiring a processed RGB image, then adjusting the lens shading + table to make it uniform. It seems that doing this 3-5 times gives much + better results than just doing it once. + + At the end, all camera settings are saved into the output file, where they + can be used to set up a microscope with `load_microscope`. + """ + print("Regenerating the camera settings, including lens shading.") + print("This will only work if the camera is looking at something uniform and white.") + # Start by loading the raw image from the Pi camera. This creates a ``picamera.PiBayerArray``. + + openflexurerc = config.load_config() + + with StreamingCamera(config=openflexurerc) as cam: + lens_shading_table = np.zeros(cam.camera._lens_shading_table_shape(), dtype=np.uint8) + 32 + gains = np.ones_like(lens_shading_table, dtype=np.float) + max_res = cam.camera.MAX_RESOLUTION + + # Open the microscope and start with flat (i.e. no) lens shading correction. + cam.start_preview() + logging.info("Stopping worker thread during calibration, to avoid GPU memory issues") + cam.stop_worker() + + def get_rgb_image(): # shorthand for taking an RGB image + return cam.array(use_video_port=True, resize=(max_res[0]//2, max_res[1]//2)) + + # Adjust the shutter speed until the brightest pixels are giving a set value (say 220) + for i in range(3): + cam.camera.shutter_speed = int(cam.camera.shutter_speed * 150.0 / np.max(get_rgb_image())) + time.sleep(1) + + for i in range(n_iterations): + print("Optimising lens shading, pass {}/{}".format(i+1, n_iterations)) + # Take an RGB (i.e. processed) image, and calculate the change needed in the shading table + images = [] #averaging to reduce noise + + for j in range(images_to_average): + images.append(get_rgb_image()) + + rgb_image = np.mean(images, axis=0, dtype=np.float) + incremental_gains = lens_shading_correction_from_rgb(rgb_image, 64//2) + gains *= incremental_gains + # Apply this change (actually apply a bit less than the change) + cam.camera.lens_shading_table = gains_to_lst(gains*32) + time.sleep(2) + + # Fix the AWB gains so the image is neutral + channel_means = np.mean(np.mean(get_rgb_image(), axis=0, dtype=np.float), axis=0) + old_gains = cam.camera.awb_gains + cam.camera.awb_gains = (channel_means[1]/channel_means[0] * old_gains[0], channel_means[1]/channel_means[2]*old_gains[1]) + time.sleep(1) + + # Adjust shutter speed to make the image bright but not saturated + for i in range(3): + cam.camera.shutter_speed = int(cam.camera.shutter_speed * 230.0 / np.max(get_rgb_image())) + time.sleep(1) + + # Storing shading table + lens_shading_table = cam.camera.lens_shading_table + + # Saving shading table to disk + output_path = os.path.join(os.path.expanduser("~"), output_fname) + np.save(output_path, lens_shading_table) + print("Lens shading table written to {}".format(output_path)) + + cam.config = {'shading_table_path': output_path} + + settings = cam.config + + for k in settings: + print("{}: {}".format(k, settings[k])) + + logging.debug("Merging config...") + config.merge_config(settings, safe=True, backup=True) + + +if __name__ == '__main__': + generate_lens_shading_table_closed_loop() \ No newline at end of file