├── __init__.py ├── images ├── building.jpg ├── streets.jpg └── streets_gray.jpg ├── plots ├── eq_building.png ├── eq_streets.png └── eq_streets_gray.png ├── results ├── equalized_streets.jpg ├── equalized_building.jpg └── equalized_streets_gray.jpg ├── requirements.txt ├── main.py ├── Readme.md └── dg_clahe.py /__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /images/building.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dimimal/dual_gamma_clahe/HEAD/images/building.jpg -------------------------------------------------------------------------------- /images/streets.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dimimal/dual_gamma_clahe/HEAD/images/streets.jpg -------------------------------------------------------------------------------- /plots/eq_building.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dimimal/dual_gamma_clahe/HEAD/plots/eq_building.png -------------------------------------------------------------------------------- /plots/eq_streets.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dimimal/dual_gamma_clahe/HEAD/plots/eq_streets.png -------------------------------------------------------------------------------- /images/streets_gray.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dimimal/dual_gamma_clahe/HEAD/images/streets_gray.jpg -------------------------------------------------------------------------------- /plots/eq_streets_gray.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dimimal/dual_gamma_clahe/HEAD/plots/eq_streets_gray.png -------------------------------------------------------------------------------- /results/equalized_streets.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dimimal/dual_gamma_clahe/HEAD/results/equalized_streets.jpg -------------------------------------------------------------------------------- /results/equalized_building.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dimimal/dual_gamma_clahe/HEAD/results/equalized_building.jpg -------------------------------------------------------------------------------- /results/equalized_streets_gray.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dimimal/dual_gamma_clahe/HEAD/results/equalized_streets_gray.jpg -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | anyio==3.5.0 2 | async-generator==1.10 3 | attrs==21.4.0 4 | axis==44 5 | black==22.1.0 6 | block==0.0.5 7 | certifi==2021.10.8 8 | charset-normalizer==2.0.10 9 | click==8.0.3 10 | contextvars==2.4 11 | cycler==0.11.0 12 | dataclasses==0.8 13 | decorator==4.4.2 14 | dual==0.0.8 15 | h11==0.12.0 16 | httpcore==0.14.5 17 | httpx==0.22.0 18 | idna==3.3 19 | imageio==2.14.0 20 | immutables==0.16 21 | importlib-metadata==4.8.3 22 | iniconfig==1.1.1 23 | kiwisolver==1.3.1 24 | matplotlib==3.3.4 25 | mypy-extensions==0.4.3 26 | networkx==2.5.1 27 | numpy==1.19.5 28 | packaging==21.3 29 | pathspec==0.9.0 30 | Pillow==8.4.0 31 | pkg_resources==0.0.0 32 | platformdirs==2.4.0 33 | pluggy==1.0.0 34 | py==1.11.0 35 | pyparsing==3.0.7 36 | pytest==6.2.5 37 | python-dateutil==2.8.2 38 | PyWavelets==1.1.1 39 | rfc3986==1.5.0 40 | scikit-image==0.17.2 41 | scipy==1.5.4 42 | six==1.16.0 43 | sniffio==1.2.0 44 | tifffile==2020.9.3 45 | toml==0.10.2 46 | tomli==1.2.3 47 | typed-ast==1.5.2 48 | typing_extensions==4.0.1 49 | xmltodict==0.12.0 50 | zipp==3.6.0 51 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | import os 2 | import argparse 3 | from skimage import io 4 | from dg_clahe import dual_gamma_clahe 5 | import matplotlib.pyplot as plt 6 | from typing import List 7 | 8 | 9 | def parse_kernel(input_list: str) -> List: 10 | if "," not in input_list: 11 | raise ValueError(f"No , seperation for kernel values") 12 | input_list = input_list.split(",") 13 | input_list = [int(i.strip()) for i in input_list] 14 | if len(input_list) > 2: 15 | raise ValueError( 16 | f"kernel should be either int or a sequence of 2 ints but got {input}" 17 | ) 18 | if len(input_list) == 1: 19 | return input_list[0], input_list[1] 20 | else: 21 | return input_list 22 | 23 | 24 | def file_exists(file_): 25 | if not os.path.exists(file_): 26 | raise ValueError(f"Image file not found {file_}") 27 | 28 | 29 | def check_folder(folder): 30 | if os.path.isfile(folder): 31 | raise ValueError( 32 | f"Output directory is an existing file {folder}, please set a folder here or leave it default." 33 | ) 34 | elif not os.path.isdir(folder): 35 | os.makedirs(folder) 36 | return folder 37 | 38 | 39 | def parse_arguments(): 40 | parser = argparse.ArgumentParser() 41 | parser.add_argument("image", help="The image path", type=str) 42 | parser.add_argument( 43 | "--kernel", 44 | help="Size of the kernel, if int then its (height, width)", 45 | type=parse_kernel, 46 | default=[32, 32], 47 | ) 48 | parser.add_argument( 49 | "--alpha", help="Alpha parameter of the algorithm", type=float, default=40 50 | ) 51 | parser.add_argument( 52 | "--delta", help="The Delta threshold of the algorithm", type=int, default=50 53 | ) 54 | parser.add_argument( 55 | "--p", 56 | help="The factor for the computation of clip limits", 57 | type=float, 58 | default=1.5, 59 | ) 60 | parser.add_argument( 61 | "--show", 62 | help="Display the 2 figures with matplotlib, before and after equalization", 63 | action="store_true", 64 | ) 65 | parser.add_argument( 66 | "--out", 67 | help="Output directory of the equalized image. Default folder is the ./images folder", 68 | default="./out_dir/", 69 | type=check_folder, 70 | ) 71 | return parser.parse_args() 72 | 73 | 74 | def main(args): 75 | image_name = "equalized_" + args.image.split(os.sep)[-1] 76 | image = io.imread(args.image) 77 | equalized_image = dual_gamma_clahe( 78 | image.copy(), 79 | block_size=args.kernel, 80 | alpha=args.alpha, 81 | delta=args.delta, 82 | pi=args.p, 83 | bins=256, 84 | ) 85 | 86 | if args.show: 87 | if image.ndim == 2: 88 | cmap = "gray" 89 | else: 90 | cmap = None 91 | fig, ax = plt.subplots(1, 2) 92 | ax[0].imshow(image, cmap=cmap) 93 | ax[1].imshow(equalized_image, cmap=cmap) 94 | ax[0].set_title("Input Image") 95 | ax[1].set_title("Equalized Image ") 96 | plt.show() 97 | 98 | # Store image 99 | io.imsave(os.path.join(args.out, image_name), equalized_image) 100 | 101 | 102 | if __name__ == "__main__": 103 | args = parse_arguments() 104 | main(args) 105 | -------------------------------------------------------------------------------- /Readme.md: -------------------------------------------------------------------------------- 1 | ## Automatic Contrast-Limited Adaptive Histogram Equalization With Dual Gamma Correction 2 | 3 | This is the implementation in python of the algorithm of the paper `Automatic Contrast-Limited Adaptive Histogram 4 | Equalization With Dual Gamma Correction` [link here](https://ieeexplore.ieee.org/document/8269243). This is not an official repository. In the algorithm we use the HSV color space 5 | and specifically the V channel to equalize our images. The algorithm works with grayscale images as well. The proper selection of 6 | parameters is important in order to get a good result in RGB and Gray images. Before we process the image, we pad the image with the 7 | reflection. Reflection, reflects the columns and rows at the sides of the image. The rationale behind this approach, is that the blocks 8 | might not fit in the image properly. Thus, either we had to crop the image or pad and then return the original image size. The first approach could 9 | have affect our results. Vectorization methods were applied where possible. The algorithm spans across 2 double for loops, in the first one we process 10 | the image in blocks, where we calculate each block's histograms and all the necessary parameters. This loop instead of running it per pixel taking O(WxH) where 11 | W and H are the width and height of image respectively; it takes O(kxl) where kxl is the amount of fitted blocks per image dimension. The second step is the bilinear interpolation 12 | of pixels. Because we process the image per block, some pixels which rely on the boundaries of the blocks may have quite different values that create artifacts. To address this problem 13 | we apply bilinear interpolation where the value of each pixel is interpolated among the values of 4 blocks. 14 | 15 | ## Prerequisites 16 | Create your environment and install the dependencies required for this project. 17 | 18 | ```commandline 19 | python3 -m venv env 20 | source env/bin/activate 21 | pip install pip --upgrade 22 | pip install -r requirements.txt 23 | ``` 24 | 25 | To execute the algorithm run the `main.py` script. Running the main function with the -h flag 26 | you can get the list of arguments that you can pass to the main. You pass all the necessary arguments 27 | noted here in order to run the algorithm. 28 | 29 | ``` 30 | usage: main.py [-h] [--kernel KERNEL] [--alpha ALPHA] [--delta DELTA] [--p P] 31 | [--show] [--out OUT] 32 | image 33 | 34 | positional arguments: 35 | image The image path 36 | 37 | optional arguments: 38 | -h, --help show this help message and exit 39 | --kernel KERNEL Size of the kernel, if int then its (height, width) 40 | --alpha ALPHA Alpha parameter of the algorithm 41 | --delta DELTA The Delta threshold of the algorithm 42 | --p P The factor for the computation of clip limits 43 | --show Display the 2 figures with matplotlib, before and after 44 | equalization 45 | --out OUT Output directory of the equalized image. Default folder is 46 | the ./images folder 47 | 48 | ``` 49 | 50 | ## Running 51 | 52 | `python main.py ./images/streets_gray.jpg --kernel 32,32 --alpha 40 --delta 50 --p 1.5 --show` 53 | 54 | 55 | `python main.py ./images/streets.jpg --kernel 32,32 --alpha 40 --delta 50 --p 1.5 --show` 56 | 57 | `python main.py ./images/building.jpg --kernel 32,32 --alpha 40 --delta 50 --p 1.5 --show` 58 | 59 | ## Results 60 | 61 | > RGB Example 62 | 63 | 64 | > Grayscale Example 65 | 66 | -------------------------------------------------------------------------------- /dg_clahe.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | from skimage import io 3 | from skimage.color import rgb2hsv, hsv2rgb 4 | import matplotlib.pyplot as plt 5 | from typing import List, Union 6 | 7 | 8 | def compute_clip_limit( 9 | block: np.ndarray, alpha: float = 40, pi: float = 1.5, R: int = 255 10 | ) -> int: 11 | """This function computes the beta clip limits of each image block 12 | 13 | Args: 14 | block (np.ndarray): The block of each image 15 | alpha (float): The alpha variance enhance parameter 16 | pi (float): The P factor enhanement 17 | R (int): The color range of the image 18 | 19 | Returns: 20 | (int): The beta clip limit for the histogram blocks 21 | """ 22 | avg = block.mean() 23 | l_max = block.max() 24 | n = l_max - block.min() 25 | sigma = block.std() 26 | m = block.size 27 | return int( 28 | (m / n) * (1 + (pi * l_max / R) + (alpha * sigma / 100.0) / (avg + 0.001)) 29 | ) 30 | 31 | 32 | def clip_and_redistribute_hist(hist: np.ndarray, beta: int) -> np.ndarray: 33 | """Clips and redistribute the excess values of the histograms among the bins using the block clip value beta 34 | 35 | Args: 36 | hist (np.ndarray): The block's histogram 37 | beta (int): The clipping value 38 | 39 | Returns: 40 | hist (np.ndarray): The redistributed histogram of shape (n_height_blocks, n_width_blocks, bins) 41 | """ 42 | # compute the exceeded pixels mask 43 | mask = hist > beta 44 | exceed_values = hist[mask] 45 | bin_value = (exceed_values.sum() - exceed_values.size * beta) // hist.size 46 | hist[mask] = beta 47 | hist += bin_value 48 | return hist 49 | 50 | 51 | def compute_gamma(l_max: int, d_range: int, weighted_cdf: np.ndarray) -> np.ndarray: 52 | """Computes the Gamma(l) function from paper in a vectorized way across the range of the color space: 53 | https://www.researcher-app.com/paper/708253 54 | 55 | Args: 56 | l_max (int): The global max value of the image 57 | d_range (int): The range of the colors in the image 58 | weighted_cdf (np.ndarray): The normalized CDF_w() 59 | 60 | Returns: 61 | (np.ndarray): The gamma vector of size nbins 62 | """ 63 | return l_max * np.power(np.arange(d_range + 1) / l_max, (1 + weighted_cdf) / 2.0) 64 | 65 | 66 | def compute_w_en(l_alpha: int, l_max: int, cdf: np.ndarray) -> np.ndarray: 67 | """Computes the W_en function according to the paper in a vectorized way, for the entire cdf: 68 | https://www.researcher-app.com/paper/708253I 69 | 70 | Args: 71 | l_alpha (float): The global l_a variable of the 75th percentile according to the CDF 72 | l_max (int): the global max intensity value 73 | cdf (np.ndarray): The normalized cdf 74 | 75 | Returns: 76 | (np.ndarray): The W_en factors of range of the bin size 77 | """ 78 | return np.power(l_max / l_alpha, 1 - (np.log(np.e + cdf) / 8)) 79 | 80 | 81 | def compute_block_coords( 82 | center_block: int, block_index: int, index: int, n_blocks: int 83 | ) -> tuple: 84 | """This function computes the neighbour block coordinates to interpolate from the histogram. This computes the coords 85 | of the interpolating blocks from both axis (x, y) 86 | 87 | Args: 88 | center_block (int): The index of the center block along the pixel coords 89 | block_index (int): The index of the block in histogram coords 90 | index (int): The current index of pixel along axis 91 | n_blocks (int): The amount of blocks along axis 92 | 93 | Returns: 94 | (int, int): The indexes of the surrounding blocks for each axis 95 | """ 96 | if index < center_block: 97 | block_min = block_index - 1 98 | if block_min < 0: 99 | block_min = 0 100 | block_max = block_index 101 | else: 102 | block_min = block_index 103 | block_max = block_index + 1 104 | if block_max >= n_blocks: 105 | block_max = block_index 106 | return block_min, block_max 107 | 108 | 109 | def compute_mn_factors(coords: tuple, index: int) -> float: 110 | """Function which computes the interpolation factors of m, n according to the paper. Depending on the axis, it returns 111 | the proper values 112 | https://www.researcher-app.com/paper/708253 113 | 114 | Args: 115 | coords (tuple): The block coordinates (x, y) 116 | index (int): The pixel index 117 | 118 | Returns: 119 | (float): The m or n interpolation factors 120 | """ 121 | if coords[1] - coords[0] == 0: 122 | return 0 123 | else: 124 | return (coords[1] - index) / (coords[1] - coords[0]) 125 | 126 | 127 | def dual_gamma_clahe( 128 | image: np.ndarray, 129 | block_size: Union[int, List] = [32, 32], 130 | alpha: float = 20, 131 | pi: float = 1.5, 132 | delta: float = 50, 133 | bins: int = 256, 134 | ): 135 | """The dual gamma clahe algorithm taking as inputs the image in numpy format along with the parameters. The algorithm transforms 136 | the image in HSV if its RGB and applies the equalization on the V channel. If grayscale image applied, then its using it as is. 137 | 138 | Args: 139 | image (np.ndarray): The image in np format, either grayscale or RGB 140 | block_size (int, int): The size of the selected block window 141 | alpha (float): The alpha variance enhance parameter 142 | pi (float): The P factor enhanement 143 | delta (float): The threshold value to aply T1 or Gamma depending on the threshold 144 | bins (int): The number of bins 145 | 146 | Returns: 147 | (np.ndarray): The enhanced image using dual gamma 148 | """ 149 | ndim = image.ndim 150 | R = bins - 1 151 | if R != 255: 152 | raise ValueError( 153 | f"The range should be 256. This algorithm hasn't been tested on a different value, but given {bins}" 154 | ) 155 | if ndim == 3 and image.shape[2] == 3: 156 | hsv_image = rgb2hsv(image) 157 | gray_image = hsv_image[:, :, 2] 158 | gray_image = np.clip(gray_image * 255, 0, 255) 159 | elif ndim == 2: 160 | gray_image = image.copy().astype(np.float) 161 | else: 162 | raise ValueError( 163 | f"Wrong number of shape or dimensions. Either single or 3 channel but image has shape {image.shape}" 164 | ) 165 | 166 | if isinstance(block_size, int): 167 | width_block = block_size 168 | height_block = block_size 169 | block_size = (block_size, block_size) 170 | elif isinstance(block_size, List): 171 | assert ( 172 | len(block_size) == 2 173 | ), f"block_size dimension is not int or (tuple, tuple) but {len(block_size)}" 174 | height_block, width_block = block_size 175 | 176 | # Compute global histogram and global values 177 | glob_l_max = gray_image.max() 178 | glob_hist = np.histogram(gray_image, bins=bins)[0] 179 | glob_cdf = np.cumsum(glob_hist) 180 | glob_cdf = glob_cdf / glob_cdf[-1] 181 | glob_l_a = np.argwhere(glob_cdf > 0.75)[0] 182 | 183 | # Compute the padding values to pad the image 184 | pad_start = [height_block // 2, width_block // 2] 185 | pad_end = [ 186 | (k - im % k) % k + int(np.ceil(k / 2.)) 187 | for k, im in zip(block_size, gray_image.shape) 188 | ] 189 | 190 | unpad_indices = tuple( 191 | [ 192 | slice(pad_h, im - pad_w) 193 | for pad_h, pad_w, im in zip(pad_start, pad_end, gray_image.shape) 194 | ] 195 | ) 196 | 197 | # Pad the image with reflect mode for color invariance 198 | gray_image = np.pad( 199 | gray_image, 200 | [[pad_h, pad_w] for pad_h, pad_w in zip(pad_start, pad_end)], 201 | mode="reflect", 202 | ) 203 | 204 | pad_height, pad_width = gray_image.shape[:2] 205 | n_height_blocks = int(pad_height / block_size[0]) 206 | n_width_blocks = int(pad_width / block_size[1]) 207 | hists = np.zeros((n_height_blocks, n_width_blocks, bins)) 208 | beta_thresholds = np.zeros((n_height_blocks, n_width_blocks)) 209 | result = np.zeros_like(gray_image) 210 | for ii in range(n_height_blocks): 211 | for jj in range(n_width_blocks): 212 | 213 | # Compute the block range and max block value 214 | max_val_block = gray_image[ 215 | ii : ii + block_size[0], jj : jj + block_size[1] 216 | ].max() 217 | r_block = ( 218 | max_val_block 219 | - gray_image[ii : ii + block_size[0], jj : jj + block_size[1]].min() 220 | ) 221 | 222 | hists[ii, jj] = np.histogram( 223 | gray_image[ii : ii + block_size[0], jj : jj + block_size[1]], bins=bins 224 | )[0] 225 | beta_thresholds[ii, jj] = compute_clip_limit( 226 | gray_image[ii : ii + block_size[0], jj : jj + block_size[1]], 227 | alpha=alpha, 228 | pi=pi, 229 | R=R, 230 | ) 231 | hists[ii, jj] = clip_and_redistribute_hist( 232 | hists[ii, jj], beta_thresholds[ii, jj] 233 | ) 234 | 235 | pdf_min = hists[ii, jj].min() 236 | pdf_max = hists[ii, jj].max() 237 | 238 | weighted_hist = pdf_max * (hists[ii, jj] - pdf_min) / (pdf_max - pdf_min) 239 | weighted_cum_hist = np.cumsum(weighted_hist) 240 | pdf_sum = weighted_cum_hist[-1] 241 | weighted_cum_hist /= pdf_sum 242 | 243 | # Equalize histogram here!!! 244 | hists[ii, jj] = np.cumsum(hists[ii, jj]) 245 | norm_cdf = hists[ii, jj] / hists[ii, jj, -1] 246 | 247 | # Compute Wen T1 and Gamma 248 | w_en = compute_w_en(l_max=glob_l_max, l_alpha=glob_l_a, cdf=norm_cdf) 249 | tau_1 = max_val_block * w_en * norm_cdf 250 | gamma = compute_gamma( 251 | l_max=glob_l_max, d_range=R, weighted_cdf=weighted_cum_hist 252 | ) 253 | 254 | if r_block > delta: 255 | hists[ii, jj] = np.maximum(tau_1, gamma) 256 | else: 257 | hists[ii, jj] = gamma 258 | 259 | # Bilinear interpolation 260 | for i in range(pad_height): 261 | for j in range(pad_width): 262 | p_i = int(gray_image[i][j]) 263 | 264 | # Get current block index 265 | block_x = i // block_size[0] 266 | block_y = j // block_size[1] 267 | 268 | # Get the central indexes of the running block 269 | center_block_x = block_x * block_size[0] + n_height_blocks // 2 270 | center_block_y = block_y * block_size[1] + n_width_blocks // 2 271 | 272 | # Compute the block coordinates to interpolate from 273 | block_y_a, block_y_c = compute_block_coords( 274 | center_block_x, block_x, i, n_height_blocks 275 | ) 276 | block_x_a, block_x_b = compute_block_coords( 277 | center_block_y, block_y, j, n_width_blocks 278 | ) 279 | 280 | # Block image coordinates 281 | y_a = block_y_c * block_size[0] + block_size[0] // 2 282 | y_c = block_y_a * block_size[0] + block_size[0] // 2 283 | x_a = block_x_a * block_size[1] + block_size[1] // 2 284 | x_b = block_x_b * block_size[1] + block_size[1] // 2 285 | 286 | m = compute_mn_factors((y_a, y_c), i) 287 | n = compute_mn_factors((x_a, x_b), j) 288 | 289 | Ta = hists[block_y_c, block_x_a, p_i] 290 | Tb = hists[block_y_c, block_x_b, p_i] 291 | Tc = hists[block_y_a, block_x_a, p_i] 292 | Td = hists[block_y_a, block_x_b, p_i] 293 | 294 | result[i, j] = int( 295 | m * (n * Ta + (1 - n) * Tb) + (1 - m) * (n * Tc + (1 - n) * Td) 296 | ) 297 | 298 | 299 | result = result[unpad_indices] 300 | result = np.clip(result, 0, R) 301 | if ndim == 3: 302 | result = result / 255.0 303 | hsv_image[:, :, 2] = result 304 | return (255 * hsv2rgb(hsv_image)).astype(np.uint8) 305 | else: 306 | return result.astype(np.uint8) 307 | 308 | 309 | if __name__ == "__main__": 310 | path = "./images/streets.jpg" 311 | image = io.imread(path) 312 | equalized_image = dual_gamma_clahe( 313 | image.copy(), block_size=32, alpha=100.0, delta=50.0, pi=1.5, bins=256 314 | ) 315 | # equalized_image = dual_gamma_clahe(image.copy(), block_size=32, alpha=10., delta=70., pi=1.5, bins=256) 316 | # equalized_image = dual_gamma_clahe(image.copy(), block_size=64, alpha=80., delta=40., pi=3.5, bins=256) 317 | fig, ax = plt.subplots(1, 2) 318 | if image.ndim == 2: 319 | cmap = "gray" 320 | else: 321 | cmap = None 322 | ax[0].imshow(image, cmap=cmap) 323 | ax[1].imshow(equalized_image, cmap=cmap) 324 | ax[0].set_title("Input Image") 325 | ax[1].set_title("Equalized Image ") 326 | ax[0].axis("off") 327 | ax[1].axis("off") 328 | plt.show() 329 | --------------------------------------------------------------------------------