Commit 2cf02508 authored by Joel Collins's avatar Joel Collins
Browse files

Calibration now stores to a config file, which can be loaded

parent f0e638f8
Loading
Loading
Loading
Loading
+39 −12
Original line number Diff line number Diff line
@@ -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,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
@@ -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()

+4 −1
Original line number Diff line number Diff line
@@ -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.

@@ -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)
+29 −2
Original line number Diff line number Diff line
@@ -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:
        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)
+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