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:
Show 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')
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:
Show 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)
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).
Show 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")
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:
Show 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()
Show 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()
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.