Source code for bioprinter

"""Bioprinter: print images with colored bacteria and yeast.

This implements the `bioprint` function, which takes an image and writes a CSV
file that the Labcyte Echo dispenser can use to print the pictures on a plate
using yeast, coli, ...

Written by Zulko at the Edinburgh Genome Foundry.

Original idea and Matlab code by Mike Shen:
https://github.com/mshen5/BioPointillism
"""

from collections import Counter
import csv

import numpy as np
from PIL import Image


def _rownumber_to_rowname(num):
    """Return the row name corresponding to the row number.

    For instance 0->A, 1->B, 2->C, ... 26->AA, 27->AB, ... etc.
    """
    if num < 26:
        return "ABCDEFGHIJKLMNOPQRSTUVWXYZ"[num]
    else:
        return "".join(
            [_rownumber_to_rowname(int(num / 26) - 1), _rownumber_to_rowname(num % 26)]
        )


[docs] def bioprint( image_filename, output_filename, bg_color, pigments_wells, resolution=(192, 128), transfer_volume=2.5, pigment_well_capacity=25000, transfer_rate=150, quantified_image_filename=None, ): """Generate a CSV file for Echo printing of an image. This function generates a CSV file for the Echo liquid handler to bioprint a given picture, using specified color wells. Parameters ---------- image_filename : str The path to the image file. The image can be any size and format, but should be suitable for low resolution yeast printing. Images taller than they are wide will automatically be rotated 90 degrees to maximize resolution while preserving aspect ratio. output_filename : str The name of the CSV file for the Echo. bg_color : tuple A triplet (R,G,B) of 0-255 integers indicating the background color of the original image (no pigment). pigments_wells : dict A dictionary of well names and the corresponding pigments ( e.g., {"A1": [0, 10, 20], "B1": [...]}). Only one well per pigment is supported currently. resolution : tuple, optional Resolution (width, height) of the printing plate. You must define a plate with these exact same characteristics using the Echo software. Default is (192, 128), equivalent to twice the resolution of a 1536-well plate. The aspect ratio of the image is preserved. transfer_volume : float, optional Volume in microliters of liquid used per pixel, default is 2.5 μL. pigment_well_capacity : float Volume in microliters that each pigment well can dispense. Raises an error if required pigment for any color exceeds the content of the well, i.e. if ``transfer_volume * number_pixels > well_capacity``. transfer_rate : int, optional Average number of droplet transfers per second; used to estimate printing time. quantified_image_filename : str, optional Path to save the quantified version of the picture as an image file. Examples -------- Print the EGF logo! :: bioprint( image_filename="egf_logo.jpeg", output_filename="egf_logo.csv", bg_color=[255, 255, 255], # White background pigments_wells={ "A1": [0, 0, 0], # Black "A2": [250, 120, 10] # Orange } ) """ pigments_wells, pigments_colors = zip(*pigments_wells.items()) # Constants of the problem colors = np.vstack([np.array(bg_color), np.array(pigments_colors)]).astype(float) resolution_w, resolution_h = resolution resolution_ratio = 1.0 * resolution_w / resolution_h image = Image.open(image_filename) width, height = image.size # IF THE PICTURE IS HIGHER THAN WIDE, CHANGE THE ORIENTATION if height > width: image = image.rotate(90, expand=1) height, width = width, height # RESIZE THE PICTURE TO THE PROVIDED RESOLUTION (KEEP THE ASPECT RATIO) image_ratio = 1.0 * width / height if (height > resolution_h) or (width > resolution_w): if image_ratio > resolution_ratio: new_width = resolution_w new_height = int(np.round(resolution_w / image_ratio)) else: new_width = int(np.round(resolution_h * image_ratio)) new_height = resolution_h image = image.resize((new_width, new_height)) image = np.array(image) # QUANTIFY THE ORIGINAL IMAGE WITH THE PROVIDED PIGMENTS COLORS image_color_distances = np.dstack( [ ((1.0 * image - color.reshape((1, 1, 3))) ** 2).sum(axis=2) for color in colors ] ) # now image_color_distances[x,y,i] represents the distance between color # i and the color of the image pixel at [x,y]. image_quantnumbers = image_color_distances.argmin(axis=2) # CHECK THAT WE WILL HAVE ENOUGH COLORANT max_pixels_per_color = pigment_well_capacity / transfer_volume counter = Counter(image_quantnumbers.flatten()) for color, count in counter.items(): if (color != 0) and (count > max_pixels_per_color): err_message = "Too much pixels of color #%d. " % ( color ) + "Counted %d, max authorized %d" % (count, max_pixels_per_color) raise ValueError(err_message) # WRITE THE CSV # TO DO: write the wells in an order that miminizes the Echo's travels. with open(output_filename, "w") as csvfile: writer = csv.writer(csvfile, delimiter=",") writer.writerow(["Source Well", "Destination Well", "Transfer Volume"]) for i, row in enumerate(image_quantnumbers): for j, color in enumerate(row): if color != 0: writer.writerow( [ pigments_wells[color - 1], # source well _rownumber_to_rowname(i) + str(j + 1), # target "well" transfer_volume, ] ) # ESTIMATE THE PRINTING TIME total_pixels = sum([count for (color, count) in counter.items() if color > 0]) print( "%d pixels will be printed in appr. %.1f minutes" % (total_pixels, total_pixels / transfer_rate) ) # SAVE THE QUANTIFIED VERSION OF THE IMAGE IF A FILENAME IS PROVIDED if quantified_image_filename is not None: image_quantified = np.array([colors[y] for y in image_quantnumbers]) pil_image = Image.fromarray(image_quantified.astype("uint8")) pil_image.save(quantified_image_filename)