Calibration now stores to a config file, which can be loaded
This commit is contained in:
parent
f0e638f894
commit
2cf025088b
4 changed files with 235 additions and 15 deletions
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
163
openflexure_microscope/utilities/recalibrate.py
Normal file
163
openflexure_microscope/utilities/recalibrate.py
Normal file
|
|
@ -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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue