Loading openflexure_microscope/camera/pi.py +39 −12 Original line number Diff line number Diff line Loading @@ -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 Loading @@ -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): Loading Loading @@ -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 " Loading @@ -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): Loading @@ -149,12 +171,15 @@ 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: if key in self._config: conf_dict[key] = self._config[key] return conf_dict Loading Loading @@ -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: Loading @@ -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() Loading openflexure_microscope/camera/set_picamera_gain.py +4 −1 Original line number Diff line number Diff line Loading @@ -9,6 +9,7 @@ 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. Loading @@ -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) Loading openflexure_microscope/config.py +29 −2 Original line number Diff line number Diff line Loading @@ -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. Loading @@ -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: 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) openflexure_microscope/utilities/recalibrate.py 0 → 100644 +163 −0 Original line number Diff line number Diff line 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 Loading
openflexure_microscope/camera/pi.py +39 −12 Original line number Diff line number Diff line Loading @@ -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 Loading @@ -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): Loading Loading @@ -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 " Loading @@ -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): Loading @@ -149,12 +171,15 @@ 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: if key in self._config: conf_dict[key] = self._config[key] return conf_dict Loading Loading @@ -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: Loading @@ -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() Loading
openflexure_microscope/camera/set_picamera_gain.py +4 −1 Original line number Diff line number Diff line Loading @@ -9,6 +9,7 @@ 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. Loading @@ -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) Loading
openflexure_microscope/config.py +29 −2 Original line number Diff line number Diff line Loading @@ -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. Loading @@ -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: 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)
openflexure_microscope/utilities/recalibrate.py 0 → 100644 +163 −0 Original line number Diff line number Diff line 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