import math
import os
import threading
import warnings
from concurrent.futures import ThreadPoolExecutor
from time import sleep
import cv2
import numpy as np
import xarray as xr
from numpy.polynomial.polynomial import polygrid2d, polyval2d
from owl.color.colorconv import get_converted_data, get_converted_dataset
try:
# Fused Highway SIMD block mean + subtraction for uint8 frames
# (owl/calibration/_background_simd.cpp): a plate that takes the numpy
# path below seconds takes this a fraction of one. Built into every wheel
# via hatch_build.py SIMD_MODULES.
from owl.calibration import _background_simd
except ImportError:
# A source checkout whose compiled extensions have not been (re)built yet:
# fall back to numpy/OpenCV and nudge the developer to rebuild the same way
# the rest of the repo installs (the editable, no-build-isolation install).
_background_simd = None
warnings.warn(
"owl.calibration._background_simd is not built; falling back to the "
"slower numpy background subtraction. Rebuild the compiled extensions "
"with `pip install -e . --no-build-isolation` (the editable install the "
"rest of the repo uses).",
RuntimeWarning,
stacklevel=2,
)
from ..mcam_data import bayer_dataset_to_single_channel, get_valid_data
from ..util import ndrange
def tqdm(*_, **__):
return _[0]
def _make_llt_inv(L_matrix):
return np.linalg.inv(L_matrix @ L_matrix.T)
def _make_L_matrix(led_values):
L_matrix = np.ones(shape=(len(led_values), 2)).T
L_matrix[0, :] = led_values
return L_matrix
def _calculate_chunk_means(dataset, chunk_size, mono_sensor):
chunks = (dataset.images.shape[2] // chunk_size,
dataset.images.shape[3] // chunk_size)
red_data = bayer_dataset_to_single_channel(dataset, 'red').images.data
green_data = bayer_dataset_to_single_channel(dataset, 'green').images.data
blue_data = bayer_dataset_to_single_channel(dataset, 'blue').images.data
channel_chunk_size = chunk_size // 2
data_shape = red_data.shape
new_shape = (
data_shape[:2] +
(chunks[0], channel_chunk_size) +
(chunks[1], channel_chunk_size)
)
data = np.empty(shape=(3,) + new_shape, dtype=np.uint8)
red_data = red_data[..., :chunks[0] * channel_chunk_size,
:chunks[1] * channel_chunk_size]
green_data = green_data[..., :chunks[0] * channel_chunk_size,
:chunks[1] * channel_chunk_size]
blue_data = blue_data[..., :chunks[0] * channel_chunk_size,
:chunks[1] * channel_chunk_size]
data[0] = red_data.reshape(new_shape)
data[1] = green_data.reshape(new_shape)
data[2] = blue_data.reshape(new_shape)
data_means = data.mean(axis=(-1, -3))
if mono_sensor:
data_means = data_means.mean(axis=0, keepdims=True)
return data_means
def _apply_sensor_corrections_to_means(data_means, response_matrix):
sensor_corrections = create_average_response_correction(response_matrix)
for i in np.ndindex(data_means.shape[1:]):
chunk_index = (slice(None),) + i
sensor_index = i[:2]
chunk_data = np.concatenate((np.array(data_means[chunk_index]), np.ones(1)))
data_means[chunk_index] = (sensor_corrections[sensor_index] @ chunk_data)[:-1]
def _calculate_response_columns_rgb(red_means_stack, green_means_stack,
blue_means_stack, led_values):
index_max = red_means_stack.shape[1:3]
r_column = np.empty(shape=index_max + (3,), dtype=float)
b_column = np.empty(shape=index_max + (3,), dtype=float)
led_matrix = _make_L_matrix(led_values)
LLt_inv = _make_llt_inv(led_matrix)
for j, i in ndrange(index_max):
P_red = red_means_stack[:, j, i]
R_red = P_red @ led_matrix.T @ LLt_inv
P_green = green_means_stack[:, j, i]
R_green = P_green @ led_matrix.T @ LLt_inv
P_blue = blue_means_stack[:, j, i]
R_blue = P_blue @ led_matrix.T @ LLt_inv
r_column[j, i] = np.array([R_red[0], R_green[0], R_blue[0]]).T
b_column[j, i] = np.array([R_red[1], R_green[1], R_blue[1]]).T
return r_column, b_column
def _calculate_response_columns_mono(means_stack, led_values):
index_max = means_stack.shape[1:3]
r = np.empty(shape=index_max + (1,), dtype=float)
b = np.empty(shape=index_max + (1,), dtype=float)
led_matrix = _make_L_matrix(led_values)
LLt_inv = _make_llt_inv(led_matrix)
for j, i in ndrange(index_max):
P_mono = means_stack[:, j, i]
R_mono = P_mono @ led_matrix.T @ LLt_inv
r[j, i] = R_mono[0]
b[j, i] = R_mono[1]
return r, b
def _define_response_column(mcam, target_channel, *,
illumination,
number_led_values,
led_type='rgb',
mono_sensor=False,
tqdm=tqdm):
color = np.zeros(3)
color[target_channel] = 1
led_list = getattr(illumination, led_type + '_leds')
led_value = illumination.find_max_brightness(len(led_list),
color_ratio=color)[target_channel]
illumination_color = color * led_value
illumination.color = illumination_color
illumination.clear()
dataset = mcam.acquire_full_field_of_view()
if dataset['images'].max() >= 255:
raise ValueError('Sensors are saturated without illumination. '
'Reduce either exposure time or ambient lighting.')
illumination.fill_array(led_type=led_type)
dataset = mcam.acquire_full_field_of_view()
illumination.clear()
# Define the led value that saturates the sensor
while dataset['images'].max() >= 255:
led_value /= 2
illumination_color = color * led_value
illumination.color = illumination_color
illumination.fill_array(led_type=led_type)
dataset = mcam.acquire_full_field_of_view()
# Ensure max_pixel_value is a numpy array since we want to do math on it
# otherwise we were getting strange errors with xarray
# https://github.com/pydata/xarray/issues/9424
max_pixel_value = np.asarray(dataset['images'].max())
saturated_led_value = led_value * 255 / max_pixel_value
led_values = np.linspace(0, .9 * saturated_led_value, number_led_values)
if mono_sensor:
means_stack = _create_means_stack_mono(led_values, color, illumination,
mcam, led_type, tqdm)
r_column, b_column = _calculate_response_columns_mono(means_stack,
led_values)
else:
(red_means_stack,
green_means_stack,
blue_means_stack) = _create_means_stack_rgb(led_values, color, illumination,
mcam, led_type, tqdm)
r_column, b_column = _calculate_response_columns_rgb(red_means_stack,
green_means_stack,
blue_means_stack,
led_values)
return r_column, b_column
def _create_means_stack_rgb(led_values, color, illumination, mcam, led_type, tqdm):
red_means_list = []
green_means_list = []
blue_means_list = []
# Avoid sensor saturation and led indefinitely increasing
# need to use max or a localized mean to avoid localized saturation
for led_value in tqdm(led_values):
illumination_color = color * led_value
illumination.color = illumination_color
illumination.fill_array(led_type=led_type)
dataset = mcam.acquire_full_field_of_view()
illumination.clear()
red_means = bayer_dataset_to_single_channel(dataset,
'red').images.data.mean(axis=(-1, -2))
green_means = bayer_dataset_to_single_channel(dataset,
'green').images.data.mean(axis=(-1, -2))
blue_means = bayer_dataset_to_single_channel(dataset,
'blue').images.data.mean(axis=(-1, -2))
red_means_list.append(red_means)
green_means_list.append(green_means)
blue_means_list.append(blue_means)
stack_height = len(red_means_list)
array_shape = red_means_list[0].shape
red_means_stack = np.reshape(red_means_list, (stack_height,) + array_shape)
green_means_stack = np.reshape(green_means_list, (stack_height,) + array_shape)
blue_means_stack = np.reshape(blue_means_list, (stack_height,) + array_shape)
return red_means_stack, green_means_stack, blue_means_stack
def _create_means_stack_mono(led_values, color, illumination, mcam, led_type, tqdm):
means_list = []
# Avoid sensor saturation and led indefinitely increasing
# need to use max or a localized mean to avoid localized saturation
for led_value in tqdm(led_values):
illumination_color = color * led_value
illumination.color = illumination_color
illumination.fill_array(led_type=led_type)
dataset = mcam.acquire_full_field_of_view()
illumination.clear()
means = dataset.images.data.mean(axis=(-1, -2))
means_list.append(means)
stack_height = len(means_list)
array_shape = means_list[0].shape
means_stack = np.reshape(means_list, (stack_height,) + array_shape)
return means_stack
def _define_response_column_rails(mcam, illumination, *,
number_led_values,
mono_sensor=False,
tqdm=tqdm):
illumination.clear()
dataset = mcam.acquire_full_field_of_view()
if dataset['images'].max() >= 255:
raise ValueError('Sensors are saturated without illumination. '
'Reduce either exposure time or ambient lighting.')
brightness_fraction = 1
illumination.set_brightness(brightness_fraction)
dataset = mcam.acquire_full_field_of_view()
illumination.clear()
# Define the led value that saturates the sensor
while dataset['images'].max() >= 255:
brightness_fraction /= 2
illumination.set_brightness(brightness_fraction)
dataset = mcam.acquire_full_field_of_view()
max_pixel_value = np.asarray(dataset['images']).max()
saturated_brightness_fraction = min(1, brightness_fraction * 255 / max_pixel_value)
brightness_fractions = np.linspace(0, .9 * saturated_brightness_fraction, number_led_values)
if mono_sensor:
means_stack = _create_means_stack_mono_rails(brightness_fractions,
illumination,
mcam,
tqdm,)
r_column, b_column = _calculate_response_columns_mono(means_stack,
brightness_fractions)
else:
(red_means_stack,
green_means_stack,
blue_means_stack) = _create_means_stack_rgb_rails(brightness_fractions,
illumination,
mcam,
tqdm)
r_column, b_column = _calculate_response_columns_rgb(red_means_stack,
green_means_stack,
blue_means_stack,
brightness_fractions)
return r_column, b_column
def _create_means_stack_rgb_rails(led_values, illumination, mcam, tqdm,
illumination_mode=None, color_ratio=None):
red_means_list = []
green_means_list = []
blue_means_list = []
# Avoid sensor saturation and led indefinitely increasing
# need to use max or a localized mean to avoid localized saturation
for led_value in tqdm(led_values):
if illumination_mode is not None and color_ratio is not None:
illumination.set_brightness(led_value,
illumination_mode=illumination_mode,
color_ratio=color_ratio)
else:
illumination.set_brightness(led_value)
# need to wait for light to turn on
sleep(1)
dataset = mcam.acquire_full_field_of_view()
red_means = bayer_dataset_to_single_channel(dataset,
'red').images.data.mean(axis=(-1, -2))
green_means = bayer_dataset_to_single_channel(dataset,
'green').images.data.mean(axis=(-1, -2))
blue_means = bayer_dataset_to_single_channel(dataset,
'blue').images.data.mean(axis=(-1, -2))
red_means_list.append(red_means)
green_means_list.append(green_means)
blue_means_list.append(blue_means)
illumination.clear()
stack_height = len(red_means_list)
array_shape = red_means_list[0].shape
red_means_stack = np.reshape(red_means_list, (stack_height,) + array_shape)
green_means_stack = np.reshape(green_means_list, (stack_height,) + array_shape)
blue_means_stack = np.reshape(blue_means_list, (stack_height,) + array_shape)
return red_means_stack, green_means_stack, blue_means_stack
def _create_means_stack_mono_rails(led_values, illumination, mcam, tqdm,
illumination_mode=None, color_ratio=None):
means_list = []
# Avoid sensor saturation and led indefinitely increasing
# need to use max or a localized mean to avoid localized saturation
for led_value in tqdm(led_values):
if illumination_mode is not None and color_ratio is not None:
illumination.set_brightness(led_value,
illumination_mode=illumination_mode,
color_ratio=color_ratio)
else:
illumination.set_brightness(led_value)
# need to wait for light to turn on
sleep(1)
dataset = mcam.acquire_full_field_of_view()
means = dataset.images.data.mean(axis=(-1, -2))
means_list.append(means)
illumination.clear()
stack_height = len(means_list)
array_shape = means_list[0].shape
means_stack = np.reshape(means_list, (stack_height,) + array_shape)
return means_stack
def define_response_matrix(mcam,
illumination,
number_led_values=15,
led_type='rgb',
lighting_channels=(0, 1, 2),
mono_sensor=False,
tqdm=tqdm):
"""Define the photometric response of each sensor with a 4x4 matrix.
Parameters
----------
mcam : owl.instruments.MCAM
Connection to the MCAM unit.
illumination : string ('reflection', 'transmission')
The illumination device to use to illuminate the sensors.
number_led_values : int
The number of led values for each led channel to define the response matrix.
led_type : str, optional
The kind of LED being characterized (e.g. ``'rgb'``).
lighting_channels : tuple of int, optional
The illumination channels to iterate over when probing responses.
mono_sensor : bool, optional
If True, treat the sensors as monochrome rather than 4-channel.
tqdm : optional
Progress-bar callable used to wrap the inner sweep.
Returns
-------
response_matrix : numpy array
The matrix that describes the sensor's response to the leds. It is a
M x N x 4 x 4 array of float where M and N are the shape of the sensor array.
"""
if mono_sensor:
N_rows = 2
else:
N_rows = 4
N_columns = 4
response_matrix = np.zeros(mcam.N_cameras + (N_rows, N_columns), dtype=float)
b = np.zeros(mcam.N_cameras + (N_rows - 1,))
for target_channel in lighting_channels:
(response_column,
b_temp) = _define_response_column(mcam=mcam,
target_channel=target_channel,
illumination=illumination,
number_led_values=number_led_values,
led_type=led_type,
mono_sensor=mono_sensor,
tqdm=tqdm)
response_matrix[:, :, :-1, target_channel] = response_column
b += b_temp
b /= N_columns - 1
response_matrix[:, :, :-1, -1] = b
response_matrix[:, :, -1, -1] = 1
return response_matrix
def define_response_matrix_rails(mcam,
illumination,
number_led_values=15,
mono_sensor=False,
tqdm=tqdm):
"""Define the photometric response of each sensor with a 4x4 matrix.
Parameters
----------
mcam : owl.instruments.MCAM
Connection to the MCAM unit.
illumination : owl.instruments.Fluorescence
Object to control the illumination source.
number_led_values : int
The number of led values for each led channel to define the response matrix.
mono_sensor : bool, optional
If True, treat the sensors as monochrome rather than 4-channel.
tqdm : optional
Progress-bar callable used to wrap the inner sweep.
Returns
-------
response_matrix : numpy array
The matrix that describes the sensor's response to the leds. It is a
M x N x 4 x 4 array of float where M and N are the shape of the sensor array.
"""
if mono_sensor:
N_rows = 2
else:
N_rows = 4
N_columns = 4
response_matrix = np.zeros(mcam.N_cameras + (N_rows, N_columns), dtype=float)
(response_column,
b) = _define_response_column_rails(mcam=mcam,
illumination=illumination,
number_led_values=number_led_values,
mono_sensor=mono_sensor,
tqdm=tqdm,
)
response_matrix[:, :, :-1, 0] = response_column
response_matrix[:, :, :-1, -1] = b
response_matrix[:, :, -1, -1] = 1
return response_matrix
def create_response_means_stack(response_matrix,
mcam,
illumination_type,
lighting_channels,
chunk_size=40,
*,
led_type='rgb',
number_led_values=5,
mono_sensor=False,
tqdm=tqdm):
"""Create a binned pixel response that is used to create pixel corrections.
Pixel response is taken of the sensor corrected values
Parameters
----------
response_matrix : numpy array
M x N x 4 x 4 array of float where M and N are the shape of the sensor array.
mcam : owl.instruments.MCAM
Connection to the MCAM unit.
illumination_type : string ('reflection', 'transmission')
Which illumination board to use to illuminate the sensors.
lighting_channels : sequence of int
The illumination color channels exercised during the sweep.
chunk_size : int
Size in pixels of the subarrays to break the images into to determine
the local response of the sensors.
led_type : str, optional
The kind of LED being driven (e.g. ``'rgb'``).
number_led_values : int, optional
Number of LED brightness steps to sample across the sweep.
mono_sensor : bool, optional
If True, treat the sensors as monochrome rather than 4-channel.
tqdm : optional
Progress-bar callable used to wrap the LED sweep loop.
Returns
-------
response_means_stack : xarray dataset
Binned pixel values produce by a series of different led values.
"""
color = define_white_light(response_matrix)
illumination_color = np.array((0, 0, 0))
illumination = getattr(mcam, illumination_type + '_illumination')
illumination.color = illumination_color
illumination.fill_array(led_type=led_type)
mcam.acquire_full_field_of_view()
illumination.clear()
means_chunk_list = []
led_factors_list = []
unit_pixel_response = (response_matrix @ np.array(color + (1,)))[..., :3]
saturation_factor = (255 / unit_pixel_response).min()
for i in tqdm(range(number_led_values)):
led_factor = saturation_factor * i / number_led_values
illumination_color[lighting_channels,] = np.array(color)[lighting_channels,] * led_factor
illumination.color = illumination_color
illumination.fill_array(led_type=led_type)
dataset = mcam.acquire_full_field_of_view()
illumination.clear()
data_means = _calculate_chunk_means(dataset, chunk_size=chunk_size, mono_sensor=mono_sensor)
_apply_sensor_corrections_to_means(data_means,
response_matrix)
means_chunk_list.append(data_means)
led_factors_list.append(led_factor)
chunk_stack_height = len(means_chunk_list)
led_stack = np.reshape(led_factors_list, chunk_stack_height)
led_index = np.arange(chunk_stack_height)
dims = ['led_index', 'color_channels', 'image_y', 'image_x', 'chunk_y', 'chunk_x']
chunk_array_shape = means_chunk_list[0].shape
chunk_y_index = (np.arange(chunk_array_shape[3]) + .5) * chunk_size
chunk_x_index = (np.arange(chunk_array_shape[4]) + .5) * chunk_size
means_chunk_stack = np.reshape(means_chunk_list,
(chunk_stack_height,) + chunk_array_shape)
means_dataarray = xr.DataArray(means_chunk_stack,
dims=dims,
coords=[led_index,
np.arange(chunk_array_shape[0]),
np.arange(chunk_array_shape[1]),
np.arange(chunk_array_shape[2]),
chunk_y_index,
chunk_x_index])
led_factors_dataarray = xr.DataArray(led_stack,
dims=['led_index'],
coords=[led_index])
led_factors_dataarray['led_color'] = str(color)
response_means_stack = xr.Dataset({'data_means': means_dataarray,
'led_factors': led_factors_dataarray})
return response_means_stack
def create_response_means_stack_rails(response_matrix,
mcam,
illumination,
chunk_size=40,
*,
number_led_values=5,
mono_sensor=False,
tqdm=tqdm):
means_chunk_list = []
led_factors_list = []
brightness_fraction = 1
illumination.set_brightness(brightness_fraction=brightness_fraction)
dataset = mcam.acquire_full_field_of_view()
illumination.clear()
# Define the led value that saturates the sensor
while dataset['images'].max() >= 255:
brightness_fraction /= 2
illumination.set_brightness(brightness_fraction=brightness_fraction)
dataset = mcam.acquire_full_field_of_view()
max_pixel_value = np.asarray(dataset['images']).max()
saturated_brightness_fraction = min(1, brightness_fraction * 255 / max_pixel_value)
brightness_fractions = np.linspace(0, .9 * saturated_brightness_fraction, number_led_values)
for brightness_fraction in tqdm(brightness_fractions):
illumination.set_brightness(brightness_fraction=brightness_fraction)
# need to wait for light to turn on
sleep(1)
dataset = mcam.acquire_full_field_of_view()
data_means = _calculate_chunk_means(dataset, chunk_size=chunk_size, mono_sensor=mono_sensor)
_apply_sensor_corrections_to_means(data_means,
response_matrix)
means_chunk_list.append(data_means)
led_factors_list.append(brightness_fraction)
illumination.clear()
chunk_stack_height = len(means_chunk_list)
led_stack = np.reshape(led_factors_list, chunk_stack_height)
led_index = np.arange(chunk_stack_height)
dims = ['led_index', 'color_channels', 'image_y', 'image_x', 'chunk_y', 'chunk_x']
chunk_array_shape = means_chunk_list[0].shape
chunk_y_index = (np.arange(chunk_array_shape[3]) + .5) * chunk_size
chunk_x_index = (np.arange(chunk_array_shape[4]) + .5) * chunk_size
means_chunk_stack = np.reshape(means_chunk_list,
(chunk_stack_height,) + chunk_array_shape)
means_dataarray = xr.DataArray(means_chunk_stack,
dims=dims,
coords=[led_index,
np.arange(chunk_array_shape[0]),
np.arange(chunk_array_shape[1]),
np.arange(chunk_array_shape[2]),
chunk_y_index,
chunk_x_index])
led_factors_dataarray = xr.DataArray(led_stack,
dims=['led_index'],
coords=[led_index])
response_means_stack = xr.Dataset({'data_means': means_dataarray,
'led_factors': led_factors_dataarray})
return response_means_stack
def _polynomial_coefficients(chunk_values, deg):
# need to normalize x, y coords for best fitting
y = (np.arange(chunk_values.shape[0]) + .5) / chunk_values.shape[0]
x = (np.arange(chunk_values.shape[1]) + .5) / chunk_values.shape[1]
X, Y = np.array(np.meshgrid(x, y))
# create a pseudo-vandermonde matrix
vander = np.zeros(shape=(X.size, math.factorial(deg + 1)), dtype=float)
for r, index in enumerate(ndrange(X.shape)):
c = 0
# hardcode (4, 4) array shape to match array shape of photometric response
# this limits us to at max 3rd degree polynomials, but this should be fine
coefficient_array_shape = (4, 4)
for x_deg, y_deg in ndrange(coefficient_array_shape):
if x_deg + y_deg <= deg:
vander[r, c] = X[index] ** x_deg * Y[index] ** y_deg
c += 1
chunk_values_vector = chunk_values.reshape((vander.shape[0],))
coefficients = np.linalg.lstsq(vander, chunk_values_vector, rcond=None)[0]
""" reorganize coefficients into an array shape to be accepted by polyval2d
[[x0y0, x0y1, x0y2, x0y3],
[x1y0, x1y1, x1y2, x1y3],
[x2y0, x2y1, x2y2, x2y3],
[x3y0, x3y1, x3y2, x3y3]] """
coefficients_array = np.zeros(shape=coefficient_array_shape, dtype=float)
c = 0
for j, i in ndrange(coefficients_array.shape):
if j + i <= deg:
coefficients_array[j, i] = coefficients[c]
c += 1
return coefficients_array
def _define_chunk_responses(color_means, led_values):
response_offset_array = np.zeros(shape=color_means.shape[1:], dtype=float)
response_coefficient_array = np.zeros(shape=color_means.shape[1:], dtype=float)
L_matrix = _make_L_matrix(led_values)
LLt_inv = _make_llt_inv(L_matrix)
for chunk_index in ndrange(color_means.shape[1:]):
y, x, y_chunk, x_chunk = chunk_index
chunk_pixel_means = color_means[:, y, x, y_chunk, x_chunk]
chunk_response = chunk_pixel_means.T @ L_matrix.T @ LLt_inv
response_coefficient_array[chunk_index] = chunk_response[0]
response_offset_array[chunk_index] = chunk_response[1]
return response_coefficient_array, response_offset_array
def create_pixel_polynomial_coefficients(response_means_stack, deg=2,
*, tqdm=tqdm):
"""Create polynomial coefficients that describe the pixels response to the led.
Parameters
----------
response_means_stack : xarray dataset
Binned pixel values produce by a series of different led values.
deg : int
The degree of polynomial used to describe surfaces. The maximum
accepted value is 3.
tqdm : optional
Progress-bar callable used to wrap the per-sensor loop.
Returns
-------
sensor_polyco_coefficient : numpy array
An MxNx4x4 array of polynomial coefficients describing the pixel
coefficient corrections where M and N correspond to the shape of the image array.
sensor_polyco_offset : numpy array
An MxNx4x4 array of polynomial coefficients describing the pixel
offset corrections where M and N correspond to the shape of the image array.
"""
if deg > 3:
raise ValueError('deg value must be an integer less than 4.')
led_factors = response_means_stack.led_factors.data
# polynomial coefficient arrays are set to be shape (4, 4) to match the
# photometric response array shapes
number_color_channels = response_means_stack.data_means.shape[1]
camera_polyco_shape = (response_means_stack.data_means.shape[2:4] +
(4, 4) + (number_color_channels,))
sensor_polyco_coefficient = np.zeros(shape=camera_polyco_shape, dtype=float)
sensor_polyco_offset = np.zeros(shape=camera_polyco_shape, dtype=float)
def _get_surface_mean(matrix):
# assumes a 2D matrix and that the surface goes from (0, 0) to (1, 1)
mean = 0
for i, j in np.ndindex(matrix.shape):
mean += matrix[i, j] / ((i + 1) * (j + 1))
return mean
for color_channel in range(number_color_channels):
(chunks_response_coefficient_array,
chunks_response_offset_array) = _define_chunk_responses(
response_means_stack.data_means.data[:, color_channel],
led_factors)
for camera_index in tqdm(ndrange(camera_polyco_shape[:2]), desc='Per camera coefficient'):
sensor_polyco_coefficient[camera_index][..., color_channel] = _polynomial_coefficients(
chunks_response_coefficient_array[camera_index],
deg=deg)
sensor_polyco_coefficient[camera_index][..., color_channel] /= _get_surface_mean(
sensor_polyco_coefficient[camera_index][..., color_channel]
)
sensor_polyco_offset[camera_index][..., color_channel] = _polynomial_coefficients(
chunks_response_offset_array[camera_index],
deg=deg)
sensor_polyco_offset[camera_index][0, 0, color_channel] -= _get_surface_mean(
sensor_polyco_offset[camera_index][..., color_channel]
)
return sensor_polyco_coefficient, sensor_polyco_offset
def _create_single_pixel_correction(polynomial_coefficients, X, Y):
corrections = polyval2d(X, Y, polynomial_coefficients)
return corrections
def _create_array_pixel_correction(polynomial_coefficients,
X, Y, tqdm=tqdm):
array_shape = polynomial_coefficients.shape[:2]
corrections = np.zeros(shape=array_shape + X.shape, dtype=np.float32)
for camera_index in tqdm(ndrange(array_shape)):
corrections[camera_index] = _create_single_pixel_correction(
polynomial_coefficients[camera_index],
X, Y)
return corrections
[docs]
def create_pixel_corrections(coefficient_polynomial_coefficients,
offset_polynomial_coefficients,
*, image_shape, tqdm=tqdm):
""" Create a coefficient and offset pixel correction based on the polynomial coefficients.
Parameters
----------
coefficient_polynomial_coefficients : numpy array
An MxNx4x4 array of polynomial coefficients describing the pixel
coefficient corrections.
offset_polynomial_coefficients : numpy array
An MxNx4x4 array of polynomial coefficients describing the pixel
offset corrections.
image_shape : tuple
The desired shape of the images in pixel (y_pixels, x_pixels).
tqdm : optional
Progress-bar callable used to wrap the per-camera loop.
Returns
-------
coefficient_corrections : numpy array
Array of pixel correction coefficients of shape M x N x image_shape.
offset_corrections : numpy array
Array of pixel correction offsets of shape M x N x image_shape.
"""
channel_number = coefficient_polynomial_coefficients.shape[-1]
array_shape = coefficient_polynomial_coefficients.shape[:2]
corrections_shape = array_shape + image_shape + (channel_number,)
coefficient_corrections = np.zeros(shape=corrections_shape, dtype=np.float32)
offset_corrections = np.zeros(shape=corrections_shape, dtype=np.float32)
y = (np.arange(image_shape[0], dtype=np.float32) + .5) / (image_shape[0])
x = (np.arange(image_shape[1], dtype=np.float32) + .5) / (image_shape[1])
X, Y = np.array(np.meshgrid(x, y), dtype=np.float32)
for channel_index in ndrange(channel_number):
tmp_coefficient = _create_array_pixel_correction(
coefficient_polynomial_coefficients[..., channel_index],
X, Y, tqdm=tqdm)
tmp_offset = _create_array_pixel_correction(
offset_polynomial_coefficients[..., channel_index],
X, Y,
tqdm=tqdm)
for array_index in ndrange(coefficient_corrections.shape[:2]):
tmp_coefficient[array_index] = 1 / tmp_coefficient[array_index]
tmp_offset[array_index] = -tmp_offset[array_index]
coefficient_corrections[..., channel_index] = tmp_coefficient[..., None]
offset_corrections[..., channel_index] = tmp_offset[..., None]
return coefficient_corrections, offset_corrections
def _remove_unused_channels(response_matrix):
# the response matrix is a image_y, image_x, 4, 4 if on a rgb sensor mcam
# or image_y, image_x, 2, 4 on a mono sensor mcam
used_channels = []
for c in range(response_matrix.shape[-1]):
if np.sum(response_matrix[..., c]) != 0:
used_channels.append(c)
# the last column will always be added because the bottom right entry is always one.
return response_matrix[..., used_channels]
def _create_response_correction(current_response, desired_response):
# 20231023 Jed - we have changed fluorescence response matrices to be 4x4,
# but only one column is populated. This causes the correction pipeline to
# make corrects that alter the color of the images. To avoid this, we remove
# the unused columns to make a 4x2 matrix for each response matrix so that
# the correction is just to unify those column values.
current_response = _remove_unused_channels(current_response)
desired_response = _remove_unused_channels(desired_response)
correction_array_dimension = current_response.shape[2]
corrections = np.zeros(
shape=(current_response.shape[:2] + (correction_array_dimension,
correction_array_dimension)),
dtype=current_response.dtype)
if current_response.shape[-2:] == (4, 2):
# Using the pinv method on a 4x2 creates a 4x4 matrix with the first 3 rows equivalent.
# This cause the images to become grayscale after white light corrections. To avoid this
# we instead use the inverse of the responses times the desired response (element by
# element) and place the results in the matrix diagonal so that it can continue to be
# applied through matrix multiplication.
adjustments = desired_response[:3, 0] / current_response[..., :3, 0]
corrections[..., 0, 0] = adjustments[..., 0]
corrections[..., 1, 1] = adjustments[..., 1]
corrections[..., 2, 2] = adjustments[..., 2]
corrections[..., :-1, -1] = desired_response[:1, -1] - current_response[..., :1, -1]
else:
for i in ndrange(current_response.shape[:2]):
corrections[i] = desired_response @ np.linalg.pinv(current_response[i])
return corrections
def create_photometric_corrections(current_response, scale=None):
# To maintain the brightness of the pixels after the correction we do not want to create the
# correction to the identity matrix but rather to the identity matrix times some constant.
# To find an appropriate constant we use the root square mean of the response of each of the
# sensors responses to the unified illumination (all ones) and then take the mean through
# all sensors.
if scale is None:
channel_responses = current_response.sum(axis=-1)
scale = np.sqrt((channel_responses[..., :-1] ** 2).mean(axis=-1)).mean()
# 20231023 Jed - we have changed fluorescence response matrices to be 4x4,
# but only one column is populated. This causes the correction pipeline to
# make corrects that alter the color of the images. To avoid this, we remove
# the unused columns to make a 4x2 matrix for each response matrix so that
# the correction is just to unify those column values.
current_response = _remove_unused_channels(current_response)
if current_response.shape[-1] == current_response.shape[-2]:
# RGB sensor, RGB Response measurement
desired_response = np.eye(current_response.shape[-1]) * scale
else:
# Mono sensor
desired_response = np.zeros_like(current_response[0, 0])
desired_response[:-1, :-1] = scale / (desired_response.shape[-1] - 1)
desired_response[-1, -1] = 1
photometric_corrections = _create_response_correction(current_response, desired_response)
return photometric_corrections
def create_monochrome_corrections(current_response, gray_ratios=(1, 1, 1)):
ratio_sum = sum(gray_ratios)
desired_response = np.eye(4)
desired_response[:, 0] = gray_ratios[0] / ratio_sum
desired_response[:, 1] = gray_ratios[1] / ratio_sum
desired_response[:, 2] = gray_ratios[2] / ratio_sum
monochrome_corrections = _create_response_correction(current_response, desired_response)
return monochrome_corrections
def create_average_response_correction(current_response, target_sensor=None, white_balance=False):
if target_sensor is None:
desired_response = current_response.mean(axis=(0, 1))
else:
desired_response = current_response[target_sensor]
if white_balance:
average_response_correction = create_photometric_corrections(current_response, scale=None)
else:
average_response_correction = _create_response_correction(current_response,
desired_response)
return average_response_correction
def define_white_light(response_matrix):
"""Gives normalized to unit vector led values to produce white light.
Parameters
----------
response_matrix : numpy array
Array of float that model the responses of the sensors. The array is
shape (image_y, image_x, 4, 4).
Returns
-------
led_values : tuple
The unit vector values of led values to produce white light.
"""
pixels = np.ones(shape=response_matrix.shape[-2] - 1) * 128
led_values = calculate_led_for_desired_pixel(response_matrix, pixels=pixels)
led_values = led_values / np.sqrt((led_values ** 2).sum())
return tuple(led_values)
[docs]
def calculate_led_for_desired_pixel(response_matrix, pixels):
"""Gives led values that will produce the given pixel ratio.
Parameters
----------
response_matrix : numpy array
Array of float that model the responses of the sensors. The array is
shape (image_y, image_x, 4, 4).
pixels : tuple
Ratio of the desired pixel values. It must be length 3.
Returns
-------
led_values : tuple
The values of led values to produce near the desired pixel values on white paper.
"""
pix_vec = np.concatenate((np.array(pixels), np.ones(1)))[..., None]
if pix_vec.shape[0] != response_matrix.shape[-2]:
raise ValueError('Value `pixels` must match shape of given response matrix. '
f'Given `pixels` has length of {len(pixels)} while response '
f'matrix has shape {response_matrix.shape}. Expected a pixel '
f'value of length {response_matrix.shape[-2] - 1}')
led_values_shape = response_matrix.shape[:2] + (response_matrix.shape[-1] - 1,)
led_values = np.zeros(shape=led_values_shape, dtype=float)
for i in ndrange(response_matrix.shape[:2]):
led_values[i] = (np.linalg.pinv(response_matrix[i]) @ pix_vec)[:-1, 0]
led_values = led_values.mean(axis=(0, 1))
return led_values
def get_corrected_data(dataset, *, white_balance):
response_matrix = dataset.photometric_response.data
sensor_corrections = create_sensor_corrections(response_matrix,
white_balance=white_balance)
return get_converted_data(dataset, sensor_corrections)
def get_corrected_dataset(dataset, white_balance):
"""Apply photometric response to the dataset images variable
Parameters
----------
dataset : xarray Dataset
MCAM data containing image data as well as additional metadata. Must have
`photometric_response` included in the metadata for the corrections to be applied.
Image data should not be bayered.
white_balance : bool
If color correction should be applied to whiten image. If white light illumination was
used then this should be True, otherwise it should be False.
Returns
-------
corrected_dataset : xarray Dataset
"""
if 'photometric_response' not in dataset:
return dataset
response_matrix = dataset.photometric_response.data
sensor_corrections = create_sensor_corrections(response_matrix,
white_balance=white_balance)
corrected_dataset = get_converted_dataset(dataset, sensor_corrections)
response_shape = dataset.photometric_response.data.shape[-2:]
photometric_response_data = np.zeros_like(
corrected_dataset.photometric_response
)
if response_shape == (4, 2):
photometric_response_data[...] = np.asarray([
[1, 0], # red
[1, 0], # green
[1, 0], # blue
[0, 1]], # noqa
dtype=photometric_response_data.dtype
)
else:
photometric_response_data[...] = np.eye(
N=response_shape[-2], # Number of rows in the output.
M=response_shape[-1], # Number of columns in the output.
dtype=photometric_response_data.dtype
)
corrected_dataset['photometric_response'] = xr.Variable(
corrected_dataset['photometric_response'].dims,
photometric_response_data,
)
return corrected_dataset
def get_corrected_dataset_stack(dataset_stack, white_balance=False):
response_matrix = dataset_stack.photometric_response.data
sensor_corrections = create_sensor_corrections(np.asarray(response_matrix),
white_balance=white_balance)
corrected_dataset_stack = get_converted_dataset(
dataset_stack,
sensor_corrections,
)
response_shape = dataset_stack.photometric_response.shape[-2:]
new_response_matrix = np.zeros_like(response_matrix)
if response_shape[-2:] == (4, 2):
new_response_matrix[:] = np.asarray([
[1, 0], # red
[1, 0], # green
[1, 0], # blue
[0, 1]], # noqa
dtype=response_matrix.dtype
)
else:
new_response_matrix[:] = np.eye(
N=response_shape[-2], # Number of rows in the output.
M=response_shape[-1], # Number of columns in the output.
dtype=response_matrix.dtype
)
dims = dataset_stack.photometric_response.dims
corrected_dataset_stack['photometric_response'] = xr.DataArray(
data=new_response_matrix,
name='photometric_response',
dims=dims,
)
return corrected_dataset_stack
def create_sensor_corrections(response_matrix, white_balance=False):
return create_average_response_correction(
response_matrix,
white_balance=white_balance,
)
def get_pixel_corrected_dataset(dataset):
"""Apply pixel correction to the dataset images variable
Parameters
----------
dataset : xarray Dataset
MCAM data containing image data as well as additional metadata. Must have
`pixel_response_coefficient` and `pixel_response_offset` included in the
metadata for the corrections to be applied. Image data should not be bayered
and should already have sensor corrections applied.
Returns
-------
pixel_corrected_dataset : xarray Dataset
"""
(coefficient_polynomial_coefficients,
offset_polynomial_coefficients) = get_crop_binned_pixel_polynomial(dataset)
images_variable = dataset.images.variable
image_shape = images_variable.shape[2:4]
y = (np.arange(image_shape[0], dtype=np.float32) + .5) / (image_shape[0])
x = (np.arange(image_shape[1], dtype=np.float32) + .5) / (image_shape[1])
images_corrected = np.empty_like(images_variable)
N_cameras = dataset.images.shape[:2]
for i in np.ndindex(N_cameras):
coefficient_corrections = 1 / polygrid2d(
y, x,
coefficient_polynomial_coefficients[i].mean(axis=-1).T
)[..., np.newaxis]
offset_corrections = polygrid2d(
y, x,
offset_polynomial_coefficients[i].mean(axis=-1).T
)[..., np.newaxis]
image = np.asarray(images_variable[i], dtype='float32')
images_corrected[i] = np.clip((
image * coefficient_corrections -
offset_corrections
), 0, 255)
pixel_corrected_dataset = dataset.copy(deep=False)
pixel_corrected_dataset['images'] = (dataset.images.dims, images_corrected)
new_coefficient = np.zeros_like(pixel_corrected_dataset.pixel_response_coefficient)
new_coefficient[..., 0, 0] = 1
pixel_corrected_dataset['pixel_response_coefficient'] = (
pixel_corrected_dataset.pixel_response_coefficient.dims,
new_coefficient,
)
new_offset = np.zeros_like(pixel_corrected_dataset.pixel_response_offset)
pixel_corrected_dataset['pixel_response_offset'] = (
pixel_corrected_dataset.pixel_response_offset.dims,
new_offset,
)
return pixel_corrected_dataset
def correct_for_new_external_light(mcam):
illumination_types = ('transmission', 'reflection')
# make sure all light are off
for illumination_type in illumination_types:
light = getattr(mcam, illumination_types + '_illumination')
if light is not None:
light.clear()
dataset = mcam.acquire_full_field_of_view()
red_offset = bayer_dataset_to_single_channel(dataset, 'red').images.data.mean(axis=(-1, -2))
green_offset = bayer_dataset_to_single_channel(dataset, 'green').images.data.mean(axis=(-1, -2))
blue_offset = bayer_dataset_to_single_channel(dataset, 'blue').images.data.mean(axis=(-1, -2))
for illumination_type in illumination_types:
response_matrix_key = illumination_type + '_photometric_response'
if response_matrix_key in dataset:
response_matrix = dataset[response_matrix_key]
response_matrix[..., 0, 3] = red_offset
response_matrix[..., 1, 3] = green_offset
response_matrix[..., 2, 3] = blue_offset
def get_crop_binned_pixel_polynomial(dataset):
coefficient_polynomial_coefficients = \
dataset['pixel_response_coefficient'].data
offset_polynomial_coefficients = \
dataset['pixel_response_offset'].data
photometric_start_pixel_y = dataset['photometric_start_pixel_y'].data
photometric_end_pixel_y = dataset['photometric_end_pixel_y'].data
photometric_start_pixel_x = dataset['photometric_start_pixel_x'].data
photometric_end_pixel_x = dataset['photometric_end_pixel_x'].data
# Get shift factors
calibrated_image_height = photometric_end_pixel_y - photometric_start_pixel_y
calibrated_image_width = photometric_end_pixel_x - photometric_start_pixel_x
shift_y = (photometric_start_pixel_y - dataset.y.data[0]) / calibrated_image_height
shift_x = (photometric_start_pixel_x - dataset.x.data[0]) / calibrated_image_width
# Get scale factors
binning_y = dataset.y.data[1] - dataset.y.data[0]
binning_x = dataset.x.data[1] - dataset.x.data[0]
image_height = dataset.y.data[-1] - dataset.y.data[0] + binning_y
image_width = dataset.x.data[-1] - dataset.x.data[0] + binning_x
scale_y = image_height / calibrated_image_height
scale_x = image_width / calibrated_image_width
coefficient_polynomial_coefficients = shift_and_scale_array_polynomial_matrix(
coefficient_polynomial_coefficients,
(shift_y, shift_x),
(scale_y, scale_x))
offset_polynomial_coefficients = shift_and_scale_array_polynomial_matrix(
offset_polynomial_coefficients,
(shift_y, shift_x),
(scale_y, scale_x))
return coefficient_polynomial_coefficients, offset_polynomial_coefficients
def shift_and_scale_array_polynomial_matrix(polynomial_matrix, shift, scale):
# shift is proportional to the full image shape
# movement to right and down (cropping is negative)
polynomial_matrix = _shift_polynomial_matrix(
polynomial_matrix, shift[0], shift[1])
polynomial_matrix = _scale_polynomial_matrix(
polynomial_matrix, scale[0], scale[1])
return polynomial_matrix
def _scale_polynomial_matrix(polynomial_matrix, scale_y, scale_x):
# This matrix must be a collection of at least 3x3 matrices that hold 2nd order 2d polynomials.
# The values are stored as such:
# [ c_00 c_01 c_02]
# [ c_10 c_11 0 ]
# [ c_20 0 0 ]
# which represents the following polynomial:
# C[y, x] = c_00 + y * c_01 + y^2 * c_02 + x * c_10 + x^2 * c_20 + x * y * c_11
# we replace y with y * scale_y and x with x * scale_x and collect the values to compute the
# polynomial for the shifted correction:
# C[y, x] = c_00 + y * scale_y * c_01 + y^2 * scale_y^2 * c_02
# + x * scale_x * c_10 + x^2 * scale_x^2 * c_20
# + x * scale_x * y * scale_y * c_11
scale_y = scale_y[..., None, None]
scale_x = scale_x[..., None, None]
polynomial_matrix_scaled = polynomial_matrix.copy()
polynomial_matrix_scaled[..., :, 1, :] *= scale_y
polynomial_matrix_scaled[..., :, 2, :] *= scale_y ** 2
polynomial_matrix_scaled[..., 1, :, :] *= scale_x
polynomial_matrix_scaled[..., 2, :, :] *= scale_x ** 2
return polynomial_matrix_scaled
def _shift_polynomial_matrix(polynomial_matrix, shift_y, shift_x):
# This matrix must be a collection of at least 3x3 matrices that hold 2nd order 2d polynomials.
# The values are stored as such:
# [ c_00 c_01 c_02]
# [ c_10 c_11 0 ]
# [ c_20 0 0 ]
# which represents the following polynomial:
# C[y, x] = c_00 + y * c_01 + y^2 * c_02 + x * c_10 + x^2 * c_20 + x * y * c_11
# we replace y with y - z and x with x - w and collect the values to compute the
# polynomial for the shifted correction:
# C_shift[y, x] = c_02 z^2 - c_01 z + c_00 + c_20 w^2 + c_11 w z - c_10 w
# + y (-c_11 w - 2 c_02 z + c_01) + c_02 y^2 + x (-c_11 z - 2 c_20 w + c_10)
# + c_20 x^2 + c_11 y x
z = shift_y[..., None]
w = shift_x[..., None]
c_00 = polynomial_matrix[..., 0, 0, :]
c_01 = polynomial_matrix[..., 0, 1, :]
c_02 = polynomial_matrix[..., 0, 2, :]
c_10 = polynomial_matrix[..., 1, 0, :]
c_20 = polynomial_matrix[..., 2, 0, :]
c_11 = polynomial_matrix[..., 1, 1, :]
c_00_shifted = c_02 * z**2 - c_01 * z + c_00 + c_20 * w**2 + c_11 * w * z - c_10 * w
c_01_shifted = -c_11 * w - 2 * c_02 * z + c_01
c_02_shifted = c_02
c_10_shifted = -c_11 * z - 2 * c_20 * w + c_10
c_20_shifted = c_20
c_11_shifted = c_11
polynomial_matrix_shifted = np.zeros(polynomial_matrix.shape, dtype=polynomial_matrix.dtype)
polynomial_matrix_shifted[..., 0, 0, :] = c_00_shifted
polynomial_matrix_shifted[..., 0, 1, :] = c_01_shifted
polynomial_matrix_shifted[..., 0, 2, :] = c_02_shifted
polynomial_matrix_shifted[..., 1, 0, :] = c_10_shifted
polynomial_matrix_shifted[..., 2, 0, :] = c_20_shifted
polynomial_matrix_shifted[..., 1, 1, :] = c_11_shifted
return polynomial_matrix_shifted
# Cap on the worker threads ``chunk_data`` and ``subtract_background`` use.
# Both are memory-bound, and they run inside an assay or the GUI, which have
# other work to keep cores for: the SIMD kernels reach the memory bus limit by
# four threads (14 GB/s of frames on a 12-core machine, against 5 for one), so
# nothing past this would pay anyway.
_MAX_BACKGROUND_THREADS = 4
# Frames handed to the SIMD kernel per call, so a progress bar still moves.
_SIMD_BATCH_FRAMES = 32
def _background_threads(threads):
if threads is None:
return max(1, min(os.cpu_count() or 1, _MAX_BACKGROUND_THREADS))
return max(1, min(int(threads), _MAX_BACKGROUND_THREADS))
def _simd_frames(images):
# The ``(frames, y, x)`` view the SIMD kernels take, or None when they do
# not apply: not built, not uint8, or not contiguous (the kernels work in
# place, so a copy would be the wrong array to subtract from).
if _background_simd is None or images.dtype != np.uint8 or not images.flags.c_contiguous:
return None
return images.reshape(-1, *images.shape[-2:])
def _frame_block_mean(frame, chunk_size, out):
# Average every ``chunk_size`` square block. Accumulating in float32 is
# exact for uint8 blocks up to 256 pixels a side (the sum stays below
# float32's 2**24 integer limit) and within a relative 1e-7 beyond that;
# numpy sums pairwise, which keeps it there.
blocks = frame.reshape(
frame.shape[0] // chunk_size, chunk_size,
frame.shape[1] // chunk_size, chunk_size,
)
np.mean(blocks, axis=(1, 3), dtype=np.float32, out=out)
def chunk_data(dataset, chunk_size, *, threads=None):
"""
Estimate a coarse background as the mean of ``chunk_size`` square blocks.
Each image's ``(y, x)`` plane is cut into non-overlapping
``chunk_size x chunk_size`` blocks and every block is reduced to one
value. The images are memory-bound work, so frames are reduced on a
thread pool.
uint8 images take the fused SIMD kernel (``_background_simd``); other
dtypes take the numpy path, which computes the same thing.
Parameters
----------
dataset: Dataset
The dataset to chunk and extract data from.
chunk_size: int
The size of each chunk to create. Must divide both image dimensions.
threads: int, optional
Worker threads to reduce frames on, at most ``_MAX_BACKGROUND_THREADS``.
Returns
-------
background: ndarray
float32 block means, shaped like the images with ``y`` and ``x``
replaced by ``y // chunk_size`` and ``x // chunk_size``.
"""
images = np.asarray(dataset.images)
dims = dataset.images.dims
if dims[-2:] != ('y', 'x'):
raise ValueError(f"Expected the images to end in ('y', 'x') dimensions, got {dims}.")
height, width = images.shape[-2:]
if height % chunk_size or width % chunk_size:
raise ValueError(
f"Chunk size {chunk_size} must divide the image shape {(height, width)}."
)
leading_shape = images.shape[:-2]
threads = _background_threads(threads)
flat = _simd_frames(images)
if flat is not None:
means = _background_simd.block_mean(flat, chunk_size, None, threads)
return means.reshape(leading_shape + means.shape[-2:])
background = np.empty(
leading_shape + (height // chunk_size, width // chunk_size), dtype=np.float32,
)
frames = list(ndrange(leading_shape))
def reduce_frame(index):
_frame_block_mean(images[index], chunk_size, background[index])
threads = min(threads, max(1, len(frames)))
if threads == 1:
for index in frames:
reduce_frame(index)
else:
with ThreadPoolExecutor(threads) as executor:
# Consume the iterator so a worker's exception is raised here.
for _ in executor.map(reduce_frame, frames):
pass
return background
def _tqdm(x, *args, **kwargs):
return x
class _FrameBackgroundSubtractor:
"""Subtract the upsampled block mean from frames of one shape, in place.
The obvious per-frame pipeline (``cv2.resize`` the block means to a
full-resolution float32 plane, ceil it, cast it, subtract it) moves four
full planes through memory per frame and does not speed up with threads,
because it is memory bound. This does the separable bilinear resize in
two halves: the horizontal pass once per frame on the tiny block grid
with ``cv2.resize``, and the vertical pass one block row at a time as a
``(rows, 2) @ (2, width)`` matrix product, so the strip, its ceil, its
integer cast and the subtraction all stay in a core's cache. The frame
itself is read and written exactly once.
The vertical weights follow ``cv2.INTER_LINEAR``'s half-pixel convention
(source row ``(r + 0.5) / scale - 0.5``, clamped at both edges), which is
also what the viewer's shader samples, so the result matches a
``cv2.resize`` of the whole plane to float32 rounding.
"""
def __init__(self, image_shape, dtype):
self._height, self._width = image_shape
self._dtype = np.dtype(dtype)
self._local = threading.local()
self._strips = {}
def _vertical_strips(self, chunk_rows):
# Output rows grouped by the source row below them: for each group the
# slice of output rows and its ``(rows, 2)`` weights on that source
# row and the next. The last source row is duplicated by the caller
# so the bottom group can always blend with weight zero on "the next".
strips = self._strips.get(chunk_rows)
if strips is not None:
return strips
rows = np.arange(self._height, dtype=np.float64)
source = np.clip((rows + 0.5) * (chunk_rows / self._height) - 0.5, 0, chunk_rows - 1)
lower = np.floor(source).astype(np.intp)
upper_weight = (source - lower).astype(np.float32)
weights = np.stack([1 - upper_weight, upper_weight], axis=1)
boundaries = np.flatnonzero(np.diff(lower)) + 1
starts = np.concatenate([[0], boundaries])
stops = np.concatenate([boundaries, [self._height]])
strips = tuple(
(slice(int(start), int(stop)), int(lower[start]),
np.ascontiguousarray(weights[start:stop]))
for start, stop in zip(starts, stops)
)
self._strips[chunk_rows] = strips
return strips
def _buffers(self, rows):
buffers = vars(self._local).get('buffers')
if buffers is None or buffers[0].shape[0] < rows:
buffers = (
np.empty((rows, self._width), dtype=np.float32),
np.empty((rows, self._width), dtype=self._dtype),
)
self._local.buffers = buffers
return buffers
def __call__(self, frame, chunk_means):
"""Subtract the bilinear upsample of ``chunk_means`` from ``frame`` in place."""
chunk_rows = chunk_means.shape[0]
strips = self._vertical_strips(chunk_rows)
# Horizontal pass: the vertical scale is 1 so rows are copied exactly.
wide = np.empty((chunk_rows + 1, self._width), dtype=np.float32)
cv2.resize(
chunk_means, (self._width, chunk_rows), dst=wide[:chunk_rows],
interpolation=cv2.INTER_LINEAR,
)
wide[chunk_rows] = wide[chunk_rows - 1]
float_strip, integer_strip = self._buffers(
max(weights.shape[0] for _, _, weights in strips))
is_integer = np.issubdtype(frame.dtype, np.integer)
for rows, source_row, weights in strips:
count = weights.shape[0]
background = float_strip[:count]
np.matmul(weights, wide[source_row:source_row + 2], out=background)
target = frame[rows]
if is_integer:
# ``int(frame - background)`` truncated towards zero, clipped
# at zero, is ``frame - ceil(background)`` saturated at zero
# for an integer frame, so the subtraction can be a saturating
# integer one with no float copy of the frame.
np.ceil(background, out=background)
integer = integer_strip[:count]
np.copyto(integer, background, casting='unsafe')
if target.flags.c_contiguous:
cv2.subtract(target, integer, dst=target)
else:
target[...] = cv2.subtract(target, integer)
else:
np.clip(target - background, 0, 255, out=target)
def subtract_background(dataset, chunk_size, tqdm=None, *, threads=None):
"""
Subtract background from dataset images.
The background is ``chunk_data``'s block mean, bilinearly upsampled to the
image, and the subtraction saturates at zero. The images are modified in
place. uint8 images take the fused SIMD kernel (``_background_simd``),
which estimates and subtracts each frame's background in one pass; other
dtypes take the numpy/OpenCV path, which computes the same thing.
Parameters
----------
dataset : xarray Dataset
MCAM data containing image data as well as additional metadata.
chunk_size : int
The size of the chunks to use for background subtraction. Must be a divisor of the image
x and y dimensions.
tqdm : callable, optional
A progress bar function to use for tracking progress.
threads : int, optional
Worker threads for both the estimate and the subtraction.
Returns
-------
xarray Dataset
The dataset with the background subtracted from the images.
"""
if tqdm is None:
tqdm = _tqdm
image_shape = dataset.sizes['y'], dataset.sizes['x']
assert image_shape[0] % chunk_size == 0, \
(f"Dataset size in 'y' dimension ({image_shape[0]}) is "
f"not divisible by chunk size ({chunk_size}).")
assert image_shape[1] % chunk_size == 0, \
(f"Dataset size in 'x' dimension ({image_shape[1]}) is "
f"not divisible by chunk size ({chunk_size}).")
dataset.images.data = np.asarray(dataset.images.data)
images = dataset.images.data
threads = _background_threads(threads)
valid_data = get_valid_data(dataset)
leading_shape = images.shape[:-2]
leading_dims = dataset.images.dims[:-2]
if 'image_y' in leading_dims and 'image_x' in leading_dims:
image_axes = leading_dims.index('image_y'), leading_dims.index('image_x')
valid_frames = np.array([
valid_data[index[image_axes[0]], index[image_axes[1]]]
for index in ndrange(leading_shape)
], dtype=bool).reshape(leading_shape)
else:
valid_frames = np.ones(leading_shape, dtype=bool)
flat = _simd_frames(images)
if flat is not None:
# Estimate and subtract each frame's background in one fused pass, a
# batch of frames per call so the progress bar keeps moving.
flat_valid = valid_frames.reshape(-1)
batches = range(0, len(flat), _SIMD_BATCH_FRAMES)
for start in tqdm(batches, total=len(batches)):
stop = start + _SIMD_BATCH_FRAMES
_background_simd.subtract_block_background(
flat[start:stop], chunk_size, flat_valid[start:stop], threads,
)
return dataset
chunked_data = chunk_data(dataset, chunk_size=chunk_size, threads=threads)
frames = [index for index in ndrange(leading_shape) if valid_frames[index]]
subtract = _FrameBackgroundSubtractor(image_shape, images.dtype)
def subtract_frame(index):
subtract(images[index], chunked_data[index])
threads = min(threads, max(1, len(frames)))
if threads == 1:
for index in tqdm(frames):
subtract_frame(index)
else:
with ThreadPoolExecutor(threads) as executor:
for _ in tqdm(executor.map(subtract_frame, frames), total=len(frames)):
pass
return dataset