Gamma#

One of the more trivial uses of good binary segmentation (let alone best-in-class instance segmentation) is the ability to adjust an image based on foreground/background values.

Example Image#

To start off, consider this example image:

Hide code cell source
 1import matplotlib as mpl
 2import matplotlib.pyplot as plt
 3plt.style.use('dark_background') 
 4dpi = 600
 5mpl.rcParams['figure.dpi'] = dpi
 6px = 1/plt.rcParams['figure.dpi']  # pixel in inches
 7import matplotlib_inline
 8matplotlib_inline.backend_inline.set_matplotlib_formats('png')
 9
10%matplotlib inline
11
12import numpy as np
13import omnipose
14from omnipose.plot import imshow
15from pathlib import Path
16import os
17from cellpose_omni import io, plot
18omnidir = Path(omnipose.__file__).parent.parent
19basedir = os.path.join(omnidir,'docs','test_files') 
20im = io.imread(os.path.join(basedir,'e1t1_crop.tif'))
21
22imshow(im,1,cmap='gray')
2024-03-04 13:10:51,353	[INFO ]	[io.py      61   logger_setup      ]	WRITING LOG OUTPUT TO /home/kcutler/.cellpose/run.log
_images/525596d9acda7baa315fa7999c8c9fca0f558f8628ca964a9fed7812bfc6f43d.png

This image is 16-bit and already adjusted to span the entire bit depth:

1print(im.dtype, im.ptp()==(2**16-1))
uint16 True

Exposure and outliers#

Raw data is often under- or over-exposed and can contain outliers where pixels are saturated. We can simulate this by dividing the image by 2 and adding a bright pixel:

Hide code cell source
 1im_bad = im * .5 # reduce brightness by 50%
 2
 3f = 1
 4c = [0.5]*3
 5fontsize=10
 6
 7# Number of subplots in the right column
 8n = 2
 9h, w = im_bad.shape[:2]
10
11sf = w
12p = 0.0001*w # needs to be defined as fraction of width for aspect ratio to work? 
13h /= sf
14w /= sf
15
16# Calculate positions of subplots
17left = np.array([i*(w+p) for i in range(n)])*1.
18bottom = np.array([0]*n)*1.
19width = np.array([w]*n)*1.
20height = np.array([h]*n)*1.
21
22max_w = left[-1]+width[-1]
23max_h = bottom[-1]+height[-1]
24
25sf = max_w - (n-1)*p
26left /= sf
27bottom /= sf
28width /= sf
29height /= sf
30
31
32s = 6.5 * 2/4 # make it so that these appear the same size 
33fig = plt.figure(figsize=(s,s), frameon=False, dpi=300)
34
35ax = fig.add_axes([left[0], bottom[0], width[0], height[0]])
36ax.imshow(im_bad,cmap='gray')
37ax.axis('off')
38ax.set_title(r'$\bf{Underexposed}$' + '\n(min/max rescaling)',c=c,fontsize=fontsize)
39
40y,x = im.shape[0]//3,im.shape[1]//5
41im_bad[y,x] = im_bad.max()*2 # add a bright pixel 
42im_bad = omnipose.utils.rescale(im_bad)
43
44ax = fig.add_axes([left[1], bottom[1], width[1], height[1]])
45ax.imshow(im_bad,cmap='gray')
46ax.axis('off')
47ax.set_title(r'$\bf{Underexposed+outlier}$' + '\n(min/max rescaling)',c=c,fontsize=fontsize)
48
49scale = 50
50arrow_length = 0.1*scale
51dx=dy=-5
52offx=offy=-5
53ax.arrow(x - dx*arrow_length-offx, y - dy*arrow_length-offy, dx*arrow_length, dy*arrow_length,
54         width=0.01*scale, head_width=0.1*scale, head_length=0.1*scale,  
55            fc=None, ec=[1.,0,0,0.75],
56            clip_on=False,
57            length_includes_head=True)
58
59fig.subplots_adjust(wspace=0.1)
_images/64b93f349034cfeff759a788bc9fae2ff3a8ab3a7807e019992463f8efe7ce94.png

The plt.imshow command simply maps the minimum value of the image to 0 and the maximum value of the image to 1, i.e. it applies standard 0-1 min-max normalization. This explains the dark appearance once we add in a bright pixel, as most of the image gets mapped to the bottom half of the available color map.

This is annoying when visualizing images next to each other, but it is particularly problematic when we need to standardize the images we feed into a neural network. We can choose to make all images 0-1, 0-255, etc. (and these can go above or below the minimum and maximum by a little), but it is much harder for a network to learn foreground from background if the images are chaotically rescaled like the above example (chaotic meaning that the image darkening is highly sensitive to the particular condition of whether or not there are saturated pixels).

We solve this by normalizing the image not by the absolute min and max, but by percentiles. We set pixels at or below the 0.01 percentile to 0 and those at or above the 99.99th percentile to 1. (Cellpose uses 1 and 99, but this will mess up images with very few cells compared to background).

Hide code cell source
 1from omnipose.utils import normalize99
 2
 3im_fixed = normalize99(im_bad)
 4# print('normalize99() fixes the image:')
 5# imshow(np.hstack((im_bad,im_fixed)),2,cmap='gray')
 6
 7f = 1
 8c = [0.5]*3
 9fontsize=10
10
11titles = ['Min-max rescaling','Percentile rescaling','Original']
12ims = [im_bad,im_fixed,im]
13
14# Number of subplots in the right column
15n = len(ims)
16h, w = ims[0].shape[:2]
17
18sf = w
19p = 0.0001*w # needs to be defined as fraction of width for aspect ratio to work? 
20h /= sf
21w /= sf
22
23# Calculate positions of subplots
24left = np.array([i*(w+p) for i in range(n)])*1.
25bottom = np.array([0]*n)*1.
26width = np.array([w]*n)*1.
27height = np.array([h]*n)*1.
28
29max_w = left[-1]+width[-1]
30max_h = bottom[-1]+height[-1]
31
32sf = max_w - (n-1)*p
33left /= sf
34bottom /= sf
35width /= sf
36height /= sf
37
38
39s = 6.5 * 3/4 # make it so that these appear the same size 
40fig = plt.figure(figsize=(s,s), frameon=False, dpi=300)
41
42
43for i in range(n):
44    ax = fig.add_axes([left[i], bottom[i], width[i], height[i]])
45    ax.imshow(ims[i],cmap='gray')
46    ax.axis('off')
47    ax.set_title(titles[i],c=c,fontsize=fontsize,fontweight="bold")
_images/57b5435cee7300ebd2c49e82c6ff232e9d9b979917b3ef421c3320c9e65d4220.png

With an image that has been properly normalized from 0 to 1, we can further adjust it. Right now we cannot see a lot of detail in the dark parts of the image; what we can do is raise the image to some power, called gammma adjustment. Because \(0^x = 0\) and \(1^x = 1\), we can make the image globally brighter or darker without affecting the total range:

Hide code cell source
  1# %matplotlib inline
  2from omnipose.utils import sinebow
  3
  4im_gamma = []
  5gamma = [0.25, 0.5, 1, 2]
  6N = len(gamma)
  7    
  8dpi = 300
  9mpl.rcParams['figure.dpi'] = dpi
 10mpl.rcParams["axes.facecolor"] = [0,0,0,0]
 11px = 1/plt.rcParams['figure.dpi']  # pixel in inches
 12axcol = [0.5]*3
 13matplotlib_inline.backend_inline.set_matplotlib_formats('svg')
 14
 15w=6
 16labelsize = 10
 17# fig1,ax = plt.subplots(figsize=(w,w/N),facecolor='#0000',frameon=False,)
 18fig1 = plt.figure(figsize=(w,w/N), 
 19                  # frameon=False, 
 20                  facecolor='#0000',
 21                  # tight_layout={'pad':10}
 22                 )
 23offset = 0.05
 24ax = fig1.add_axes([offset,0,1-offset,1])
 25fig1.subplots_adjust(left=offset, bottom=0, right=1, top=1, wspace=0, hspace=0)
 26
 27color = sinebow(N+1)
 28for j,g in enumerate(gamma):
 29    i = im_fixed**g
 30    im_gamma.append(i)
 31    ax.hist(i.flatten(),
 32             bins=100,
 33             label='gamma = {}'.format(g),
 34             color=color[j+1],
 35             histtype='step',
 36             density=True)
 37
 38l = ax.legend(prop={'size': labelsize},
 39               frameon=False,
 40               bbox_to_anchor=(1, 1),
 41               loc='upper right', 
 42               borderaxespad=0.)
 43for text,c in zip(l.get_texts(),[color[i] for i in range(1,N+1)]):
 44    text.set_color(c)
 45    
 46for item in l.legend_handles:
 47    item.set_visible(False)
 48
 49ax.spines['top'].set_visible(False)
 50ax.spines['right'].set_visible(False)
 51ax.patch.set_alpha(0.0)
 52plt.xlabel('Intensity',size=labelsize,c=axcol,fontweight='bold')
 53plt.ylabel('PDF',size=labelsize,c=axcol,fontweight='bold')
 54
 55ax.yaxis.set_label_coords(-offset,0.5)
 56
 57
 58ax.tick_params(axis='both', colors=axcol)
 59ax.spines['bottom'].set_color(axcol)
 60ax.spines['left'].set_color(axcol)
 61
 62plt.show()
 63
 64
 65# %matplotlib inline
 66%config InlineBackend.figure_formats = ['png']
 67# mpl.use('Agg')
 68
 69h,w = im.shape[-2:]
 70
 71# Number of subplots in the right column
 72n = len(im_gamma)
 73
 74sf = w
 75p = 0.05
 76h /= sf
 77w /= sf
 78
 79# Calculate positions of subplots
 80left = np.array([i*(w+p) for i in range(n)])*1.
 81bottom = np.array([0]*n)*1.
 82width = np.array([w]*n)*1.
 83height = np.array([h]*n)*1.
 84
 85max_w = left[-1]+width[-1]
 86max_h = bottom[-1]+height[-1]
 87
 88sw = max_w
 89sh = max_h
 90
 91sf = max(sw,sh)
 92left /= sw
 93bottom /= sh
 94width /= sw
 95height /= sh
 96
 97# Create figure
 98s = 6
 99fig2 = plt.figure(figsize=(s,s*sh/sw), frameon=False, dpi=600)#,tight_layout={'pad':0})
100# fig2.patch.set_facecolor([0]*4)
101
102# Add subplots
103axes = []
104for i in range(n):
105    ax = fig2.add_axes([left[i], bottom[i], width[i], height[i]])
106    axes.append(ax)
107
108
109# fig2, axes = plt.subplots(1,4, figsize=(w,w/4))  
110# fig2.patch.set_facecolor([0]*4)
111
112sz = im.shape
113pad = 10
114width = 30
115slc = (slice(pad,pad+width),slice(sz[1]-(pad+width),sz[1]-pad),Ellipsis)
116
117for i,(ax,ig) in enumerate(zip(axes,im_gamma)):
118    ax.axis('off')
119    ig = np.stack([ig]*3+[np.ones_like(ig)],axis=-1)
120    ig[slc] = color[i+1]
121    ax.imshow(ig)
122
123fig2.subplots_adjust(left=0, bottom=0, right=1, top=1, wspace=0, hspace=0)
124# plt.show()
_images/7dcfaddc0b896d18102c14e66bba299016f3bef195c2aa78d1d7f8ceb3ce6d6e.svg _images/4c05a4b6f1305cb33a215a740ed83a579688950d3e0f9a9395f0dd08a80fceed.svg
Hide code cell source
 1datadir = omnidir.parent
 2# fig1.savefig(os.path.join(datadir,'Dissertation','figures','gammahist.pdf'),
 3#     transparent=True,bbox_inches='tight',pad_inches=0)
 4
 5file = os.path.join(datadir,'Dissertation','figures','gammahist.pdf')
 6if os.path.isfile(file): os.remove(file)
 7fig1.savefig(file,transparent=True,bbox_inches='tight',pad_inches=0)
 8
 9file = os.path.join(datadir,'Dissertation','figures','gammapics.png')
10if os.path.isfile(file): os.remove(file)
11fig2.savefig(file,transparent=True,pad_inches=0,bbox_inches='tight')

Note that fractional powers only work (without going into complex numbers) if the image values are nonnegative.

Semantic gamma normalization#

We can next use image segmentation in combination with gamma adjustment to normalize image brightness. This is very handy for making figures with images coming from different microscopes or optical configurations. To demonstrate, let's load in the image set from our mono_channel_bact notebook and the corresponding masks we made with Omnipose.

 1from pathlib import Path
 2import os
 3from cellpose_omni import io, transforms
 4
 5mask_filter='_cp_masks'
 6img_names = io.get_image_files(basedir,mask_filter,look_one_level_down=True)
 7mask_names = io.get_label_files(img_names,subfolder='masks')
 8imgs =  [io.imread(i) for i in img_names]
 9imgs = [im if im.ndim==2 else im[...,0] for im in imgs]
10masks = [io.imread(m) for m in mask_names]

Now we will compare standard normalization to what I am calling "semantic gamma normalization". My implementation of it can be found in omnipose.utils, which simply answers the question: "what is the power to which I need to raise my image such that the average background becomes equal to a given value?". From left to right, I plot im/max(im.dtype) (so min>=0 and max=1), 0-1 remapping of im (recsale), percentile remapping of im (normalize99), gamma normalization to background of 1/3, and gamma normalization of background to 1/2. The output has been set to use the same colormap and interpolation (vmin and vmax are otherwise set by the min and max of the image).

 1from omnipose.utils import rescale, normalize_image
 2
 3textcolor = [0.5]*3
 4
 5f = 1
 6labelsize = 8*f
 7fontsize = 4*f
 8fontsize3 = 12*f
 9    
10# Assume the images are stored in a nested list
11images = []
12for im, mask in zip(imgs,masks):
13    
14    # format the image
15    im = transforms.move_min_dim(im) # move the channel dimension last
16    if len(im.shape)>2:
17        im = im[:,:,1] 
18    
19    im_raw = im/np.iinfo(im.dtype).max
20    
21    im_rescale = rescale(im)
22    im_norm = normalize99(im)
23    im_gamma_3 = normalize_image(im, mask>0, target=1/3)
24    im_gamma_2 = normalize_image(im, mask>0, target=1/2)
25
26    images.append([im_raw,im_rescale,im_norm,im_gamma_3, im_gamma_2])
27
28images = images[::-1]
29titles = ['raw','minmax','percentile','$\gamma=1/3$', '$\gamma=1/2$']
30
31kwargs = {'cmap':'gray','vmin':0,'vmax':1}
32numt = [str(i) for i in range(len(images))]
33fig = omnipose.plot.image_grid(images,column_titles=titles,
34                                     # row_titles=numt,
35                                     fig_scale=6,
36                                     outline=True,
37                                     **kwargs)
38plt.show()
39
40# fig = omnipose.plot.image_grid(images[::-1],
41#                                      column_titles=numt,
42#                                      row_titles=titles,
43#                                      fig_scale=4,
44#                                      outline=True,
45#                                      order='ji',
46#                                      **kwargs)
47# plt.show()
_images/724e52cb346a100ce7d0ad4ad1800f8ca9659bf45e7e4bbc41e0a121e4328073.svg

The first column provides an essentially 'raw' view of the image, as it has not been shifted or stretched relative to the original max and min of its data type. As noted in the segmentation notebook, that first image is super dark because it is an 8-bit image (0-255), but only takes on values from 4 to 22. My code above divides by 255 for uint8 images and 65535 for the last uint16 image.

The second and third columns do stretch the image to fill the whole 0-1 range, but you can see how the images still have different background intensity. My function in columns 4 and 5 normalize the background to a constant value. Well-exposed bacterial phase contrast images seem to have a 'natural' background value of about 1/3.