## Introduction

Image compression is the process of reducing the size of an image file while maintaining its quality.

This is important because images take up a lot of memory and bandwidth, and therefore, compressing them can save time, space, and money.

A frequently used technique for image compression is vector quantization, which maps each pixel in an image to a more limited set of representative colors. One method to accomplish this is through K-Means clustering, where the image is divided into [latex]K[/latex] clusters and each pixel's color is replaced by the average color of its cluster. This results in the image being represented by [latex]K[/latex] distinct colors, which significantly reduces its size.

## K-Means

In this section, we briefly give readers a short primer on the definition and the algorithm that governs K-Means.

On a high level, K-Means is an unsupervised machine-learning algorithm that is used in clustering analysis. Given a set of data points, the algorithm groups the points into [latex]K[/latex] clusters, where [latex]K[/latex], a ** priori**, is a user-defined number. The algorithm calculates the mean of each cluster, known as the cluster centroid, and assigns each data point to the closest cluster. This process is repeated until the cluster centroids converge to stable values.

Let's make this idea more concrete by introducing the concept with proper notations.

### Problem Statement

**Given** a set [latex]\mathcal{S}[/latex] containing [latex]N[/latex] data points:

[latex] \mathcal{S} = \left\{\mathbf{x}^{(1)}, \mathbf{x}^{(2)}, \dots, \mathbf{x}^{(N)}\right\} \subseteq \mathbb{R}^D [/latex]

where the vector [latex]\mathbf{x}^{(n)}[/latex] is the [latex]n[/latex]-th sample with [latex]D[/latex] number of features, given by:

[latex]\mathbf{x}^{(n)} \in \mathbb{R}^{D} = \begin{bmatrix} x_1^{(n)} & x_2^{(n)} & \cdots & x_D^{(n)} \end{bmatrix}^{\mathrm{T}} \quad \text{where } n = 0, 1, \ldots, N.[/latex]

The K-Means algorithm aims to group the data points into a set [latex]\hat{\mathcal{C}}[/latex] containing [latex]K[/latex] clusters:

[latex] \hat{\mathcal{C}} = \left\{ \hat{C}_1, \hat{C}_2 \dots, \hat{C}_K \right\} [/latex]

where [latex] \hat{C}_k [/latex] is the set of data points [latex] \mathbf{x}^{(n)} \in \mathcal{S}[/latex] assigned by [latex] \mathcal{A}(\cdot) [/latex] (explained shortly) to the [latex] k [/latex]-th cluster:

[latex]\hat{C}_k = \left\{\mathbf{x}^{(n)} \in \mathbb{R}^{D} \middle\vert \mathcal{A}(n) = k\right\}.[/latex]

Note that [latex]\mathcal{A}(\cdot)[/latex] is the **assignment** map that "predicts" and "classifies" each data point into their respective clusters.

Since [latex] \mathcal{A}(\cdot) [/latex] is an assignment rule, an intuitive way is to find a **representative vector** [latex] \boldsymbol{v}_k [/latex] in each cluster [latex] \hat{C}_k [/latex], and assign every data point [latex] \mathbf{x}^{(n)} [/latex] that is **closest** to this representative. This is similar to the nearest-neighbour search algorithm.

What follows is the definition of centroids (cluster centers),

[latex]\boldsymbol{v}_1, \boldsymbol{v}_2, \dots, \boldsymbol{v}_K \in \mathbb{R}^{D}[/latex]

where [latex]\boldsymbol{v}_k[/latex] is the centroid of cluster [latex]C_k[/latex]. These are the representatives of each cluster and it can be proven that these centroids are nothing but the mean vector [latex]\boldsymbol{\mu}_k[/latex] of the cluster [latex]C_k[/latex].

To this end, we finalize the problem statement by defining an appropriate loss and objective function. More formally, we want to find the **assignment** [latex]\mathcal{A}(\cdot)[/latex] and the **cluster center** [latex]\boldsymbol{v}_k[/latex] such that the **sum of squared distances** between each **data point** and its cluster center is minimized. This means **partitioning** the data points according to the Voronoi Diagram.

Mathematically, we minimize the following objective function:

[latex] \begin{alignat}{3} \underset{\mathcal{A}, \boldsymbol{v}_k}{\operatorname{argmin}} &\quad& \hat{\mathcal{J}}_{\mathcal{S}}(\mathcal{A}, \boldsymbol{v}_1, \boldsymbol{v}_2, \ldots, \boldsymbol{v}_K) &= \sum_{n=1}^{N} \sum_{\mathcal{A}(n) = k} \left\|\mathbf{x}^{(n)} - \boldsymbol{v}_k \right\|^2 \\ \text{subject to} &\quad& \hat{C}_1 \sqcup \hat{C}_2 \sqcup \cdots \sqcup \hat{C}_K &= \mathcal{S} \\ &\quad& \boldsymbol{v}_1, \boldsymbol{v}_2, \ldots, \boldsymbol{v}_K &\in \mathbb{R}^D \\ \end{alignat}[/latex]

The problem in itself seems manageable since we are trying to partition the data points into [latex]K[/latex] clusters and minimize the intra-cluster distance (variances). However, jointly optimizing both the assignment map [latex]\mathcal{A}(\cdot)[/latex] and the centroids [latex]\boldsymbol{v}_k[/latex] is an NP-hard problem and is computationally challenging.

However, there are many heuristics that are used to solve the problem. We will talk about one of the most popular heuristics, Lloyd's algorithm, in the next section.

### Lloyd's Algorithm

Lloyd's algorithm is the most well-known algorithm that greedily optimizes and solves the K-Means clustering problem. It is a greedy algorithm because it optimizes and finds the best step at each iteration. Consequently, it guarantees the objective function converges to a local minimum.

What follows is a description of Lloyd's algorithm.

#### Step 1. Initialization Step.

Initialize [latex]K[/latex] cluster centers [latex]\boldsymbol{\mu}_1^{0}, \boldsymbol{\mu}_2^{0}, \dots, \boldsymbol{\mu}_K^{0}[/latex] randomly (best to be far apart) where the superscript [latex]0[/latex] denotes the iteration number [latex]t=0[/latex].

Some things to note:

- In the very first iteration, there are no data points in any cluster [latex]\hat{C}_k^{0} = \emptyset[/latex]. Therefore, the cluster centers are just randomly chosen for simplicity.
- By random, we mean that the cluster centers are randomly chosen from the data points [latex]\mathcal{S} = \left\{\mathbf{x}^{(1)}, \mathbf{x}^{(2)}, \dots, \mathbf{x}^{(N)}\right\}[/latex] and not randomly chosen from the feature space [latex]\mathbb{R}^D[/latex].
- Subsequent iterations will have data points in the clusters [latex]\hat{C}_k^{t} \neq \emptyset[/latex] and thus [latex]\boldsymbol{\mu}_k^{t}[/latex] will be the mean of the data points in cluster [latex]k[/latex].
- Each [latex]\boldsymbol{\mu}_k^{0} = \begin{bmatrix} \mu_{1k}^{0} & \mu_{2k}^{0} & \cdots & \mu_{Dk}^{0} \end{bmatrix}^{\mathrm{T}}[/latex] is a [latex]D[/latex]-dimensional vector, where [latex]D[/latex] is the number of features, and represents the mean vector of all the data points in cluster [latex]k[/latex].
- Note that [latex]\mu_{dk}^{0}[/latex] is the mean value of the [latex]d[/latex]-th feature in cluster [latex]k[/latex].
- We denote [latex]\boldsymbol{\mu} = \begin{bmatrix} \boldsymbol{\mu}_1 & \boldsymbol{\mu}_2 & \cdots & \boldsymbol{\mu}_K \end{bmatrix}^{T}_{K \times D}[/latex] to be the collection of all [latex]\boldsymbol{\mu}_1, \boldsymbol{\mu}_2, \dots, \boldsymbol{\mu}_K[/latex].

#### Step 2. Assignment Step.

Then for [latex]t=0, 1, 2, \dots[/latex], we have:

**Assignment Step (E)**: Assign each data point [latex]\mathbf{x}^{(n)}[/latex] to the closest cluster center [latex]\boldsymbol{\mu}_k^{t}[/latex],

[latex] \begin{aligned} \hat{y}^{(n), t} := \mathcal{A}^{*, t}(n) &= \underset{k \in {1, 2, \ldots, K}}{\operatorname{argmin}} \left| \mathbf{x}^{(n)} - \boldsymbol{\mu}_k^{t} \right|^2 \ \end{aligned} [/latex]

In other words, [latex]\hat{y}^{(n), t}[/latex] is the output of the optimal assignment rule [latex]\mathcal{A}^{*}[/latex] at the [latex]t[/latex]-th iteration and is the index of the cluster center [latex]\boldsymbol{\mu}_k^{t}[/latex] that is closest to [latex]\mathbf{x}^{(n)}[/latex].

#### Step 3. Update Step.

**Update Step (M)**: Update the cluster centers for the next iteration.

[latex]\begin{aligned} \boldsymbol{\mu}_k^{t+1} &= \frac{1}{\left|\hat{C}_k^{t}\right|} \sum_{\mathbf{x}^{(n)} \in \hat{C}_k^{t}} \mathbf{x}^{(n)} \end{aligned}[/latex]

Notice that the cluster center [latex]\boldsymbol{\mu}_k^{t+1}[/latex] is the mean of all data points that are assigned to cluster [latex]k[/latex] at iteration [latex]t[/latex].

#### Step 4. Repeat till Convergence

Repeat steps 2 and 3 until the centroids stop changing.

[latex]\begin{aligned}\boldsymbol{\mu}_k^{t+1} = \boldsymbol{\mu}_k^{t}\end{aligned}[/latex]

This is the convergence condition.

In the above algorithm, we have assumed without proof that the mentioned assignment map [latex]\mathcal{A}(\cdot)[/latex] and the centroids [latex]\boldsymbol{\mu}_k[/latex] does indeed minimize the objective function [latex]\mathcal{J}[/latex] at each step. To this end, we have defined what K-Means is and the algorithm behind it.

## Vector Quantization

Recall that we mentioned that vector quantization is a technique used in image compression to reduce the amount of data needed to represent an image, and that **K-Means** is a method of **vector quantization**. We shall see below that they are of equivalent form. Below is an adaptation from "Chapter 21.3. K-Means Clustering." In Probabilistic Machine Learning: An Introduction, written by Murphy, Kevin P.

Given a sequence of real-valued vector [latex]\left\{\mathbf{x}^{(n)}\right\}_{n=1}^N[/latex] where each [latex]\mathbf{x}^{(n)} \in \mathbb{R}^{D}[/latex], we can use vector quantization to perform a lossy compression as follows:

### Step 1.

First, we replace each real-valued vector [latex]\mathbf{x}^{(n)} \in \mathbb{R}^D[/latex] with a discrete symbol [latex]\mathbf{z}^{(n)} \in \{1, \ldots , K\}[/latex].

### Step 2.

Then, we define a codebook of [latex]K[/latex] prototypes [latex]\left\{\boldsymbol{\mu}_k\right\}_{k=1}^K \in \mathbb{R}^D[/latex]. Since each real-valued vector [latex]\mathbf{x}^{(n)} \in \mathbb{R}^D[/latex] is replaced with a discrete symbol [latex]\mathbf{z}^{(n)} \in \{1, \ldots , K\}[/latex], we can think of the codebook as a lookup table that maps each symbol to a prototype.

### Step 3.

Finally, we encode each real-valued vector [latex]\mathbf{x}^{(n)} \in \mathbb{R}^D[/latex] by identifying the prototype that is most similar to it, using Euclidean distance as a measure of similarity:

[latex]\begin{equation} \text{encode}\left(\mathbf{x}^{(n)}\right) = \arg \min_{k} \left|\mathbf{x}^{(n)} - \boldsymbol{\mu}_k \right|^2 \end{equation}[/latex]

The quality of a codebook can be assessed by determining the reconstruction error or the amount of distortion it causes, as expressed by the following equation:

[latex]\begin{equation} \mathcal{J} = \frac{1}{N} \sum_{n=1}^N |\mathbf{x}^{(n)} - \text{decode}\left(\text{encode}\left(\mathbf{x}^{(n)}\right)\right)|^2 = \frac{1}{N} \sum_{n=1}^N \left\|\mathbf{x}^{(n)} - \boldsymbol{\mu}_{\mathbf{z}^{(n)}}\right\|^2 \end{equation}[/latex]

where [latex]\text{decode}(k) = \boldsymbol{\mu}_k[/latex].

We see that the compression error has the same form as the K-Means cost function. And therefore we can use K-Means to perform vector quantization, and thereby achieving compression.

## A Primer on Binary Digits (Bits) and 8-bit Unsigned Integers

To better appreciate how image compression work, it is important to understand how images (and any information) are stored in computers.

Information is stored in computers as a series of bits, the smallest units of information used in a computer. They have binary values, meaning they can either be 0 or 1. A series of bits are used to represent data and constitute the backbone of all digital information.

Images are often stored in 8-bit precision, which means they are represented as 8-bit unsigned integers. An 8-bit unsigned integer is a type of integer data type that can store a positive value between 0 and 255, inclusive, without a sign bit. Each bit in an 8-bit unsigned integer requires one memory space and can only have a value of 0 or 1.

For example, let's say a pixel value is 100. It can be represented in 8-bit binary form as [latex]01100100[/latex]. In an 8-bit unsigned integer representation, the most significant bit (leftmost bit) represents the value [latex]2^{7}=128[/latex], the second most significant bit represents [latex]2^6=64[/latex], the third most significant bit represents [latex]2^5=32[/latex], and all the way till the least significant bit (rightmost bit) represents [latex]2^0=1[/latex].

This is why most images range from 0 to 255, as they are stored as 8-bit unsigned integers.

The number of bits needed to store an image depends on the size of the image, the number of color channels, and the number of bits used to represent each color channel.

**Number of Bits needed for a Positive Integer**

Now given a positive integer [latex]K[/latex], the number of bits needed to represent it is:

[latex] n_{bits} = \lceil \log_2 K \rceil [/latex]

where:

- [latex]n_{bits}[/latex] is the number of bits required;
- [latex]\lceil \cdot \rceil[/latex] is the ceiling function, which rounds up to the nearest integer.

With this formula, we can write a simple function that takes in a NumPy array and calculate the number of bits needed to store it. This function does not take into account negative numbers and many other things as it is only meant for educational purposes.

```
def calculate_num_bits(arr: np.ndarray, max_int: int) -> Tuple[int, int]:
"""
Calculate the number of bits required to store a NumPy array.
Parameters:
arr (np.ndarray): A NumPy array.
max_int (int): The maximum integer value allowed in this number system.
Returns:
num_bits (int): The number of bits required to store each value.
total_bits (int): The number of bits required to store the array.
"""
# number of bits required to store each value
num_bits = int(math.ceil(math.log2(max_int)))
total_bits = num_bits * arr.size
return num_bits, total_bits
```

## Image Compression with K-Means

In image compression, we use K-Means to group similar pixels into [latex]K[/latex] clusters. Each cluster centroid represents a representative color for the pixels in the cluster, and we can map each pixel to the closest centroid. This reduces the number of colors required to represent the image, and thus the size of the image data. The mapping of each pixel to a cluster centroid can be stored using a smaller number of bits compared to the original 24-bit RGB values.

Where did this 24-bit come from? Let's define the problem statement and find out!

### Problem Statement

Let an image be denoted by [latex]\mathbf{I} \in \mathbb{R}^{m \times n \times 3}[/latex], where [latex]m[/latex] is the width of the image, [latex]n[/latex] is the height of the image, and 3 is the number of color channels representing the red, green, and blue colors.

We have the following properties:

- There are a total of [latex]N = m \times n[/latex] pixels in the image [latex]\mathbf{I}[/latex].
- Each pixel [latex]\mathbf{x} = \begin{bmatrix} r & g & b \end{bmatrix}^T \in \mathbb{Z}_{rgb}^3[/latex] on the image [latex]\mathbf{I}[/latex] is a 3-dimensional (3-D) tuple residing in the 3-D integer space comprising the intensities of the red, green and blue channels.
- [latex]\mathbb{Z}_{rgb}[/latex] represents all integers from [latex]0[/latex] to [latex]255[/latex]. Therefore, each color channel in the pixel is of the range [latex]0[/latex] to [latex]255[/latex], and is assumed to be an 8-bit unsigned integer.

Based on our earlier discussion of bits, we require 8 bits to represent numbers from [latex]0[/latex] to [latex]255[/latex]. It follows that each pixel contains [latex]3 \times 8 = 24[/latex] bits of information. Consequently, the total number of bits required to represent the image is [latex]24 \times N = 24N[/latex] bits.

Our aim is to find a compression map [latex]h: \mathbb{R}^{N} \rightarrow \mathbb{R}^{M}[/latex], where [latex]M < N[/latex], such that the number of bits required to represent the compressed image is less than the number of bits required to represent the original image.

The compressed output [latex]\mathbf{z} = h(\mathbf{x})[/latex] is therefore called a code (compressed representation) of the input [latex]\mathbf{x}[/latex] (original representation).

We can then reconstruct the original image [latex]\mathbf{I}[/latex] from the compressed image [latex]\mathbf{z}[/latex] by a reconstruction map [latex]r: \mathbb{R}^{M} \rightarrow \mathbb{R}^{N}[/latex], such that [latex]\hat{\mathbf{x}} = r(\mathbf{z}) = r(h(\mathbf{x}))[/latex]. Note that [latex]\hat{\mathbf{x}}[/latex] is the reconstructed representation of [latex]\mathbf{x}[/latex] and may or may not be the same as [latex]\mathbf{x}[/latex]. If they are not the same, then the compression is lossy.

**Steps to Compress an Image**

Here is a step-by-step guide to using K-Means for image compression, we will later solidify this with code implementation.

**Load and read the image:**Load the image [latex]\mathbf{I}[/latex] into memory. Typically [latex]\mathbf{I}[/latex] will be converted to a NumPy array of shape [latex]m \times n \times 3[/latex].**Reshape the image data**: Reshape the image matrix [latex]\mathbf{I}[/latex] into a 2D array of size [latex]N \times 3[/latex], where [latex]N = m \times n[/latex] is the total number of pixels in the image. Each row of the array represents a pixel in the image. This step is necessary because K-Means expects a 2D array as input. One can now think of [latex]N \times 3[/latex] as a dataset [latex]\mathcal{S}[/latex] with [latex]N[/latex] data points, with [latex]D=3[/latex] features.**Apply K-Means clustering:**Apply K-Means clustering to the reshaped image data, using a user-defined number of clusters, [latex]K[/latex]. This will group similar pixels into [latex]K[/latex] clusters and calculate the cluster centroids [latex]\boldsymbol{\mu}_1, \boldsymbol{\mu}_2, \ldots, \boldsymbol{\mu}_K[/latex]. Each centroid is a 3-dimensional vector representing the RGB values of the representative color for the cluster.**Compress the image:**For each pixel in the image, find the closest cluster centroid [latex]\boldsymbol{\mu}_k[/latex] and replace the pixel with the index [latex]\hat{y}^{(n)}[/latex] of the centroid.**Store the compressed image:**Store the compressed image, which includes the cluster centroids and the mapping of each pixel to a centroid, to disk.

### Example

To put things into perspective, let's consider a simple example.

Let us consider an image of size [latex]100 \times 100 \times 3[/latex] pixels.

Then there are [latex]N = 100 \times 100 = 10,000[/latex] pixels in the image, and each pixel is represented by a 3-dimensional vector.

Then assuming that each value in the RGB tuple is stored with 8 bits of precision, then the total number of bits required to represent the image is

[latex] 24N = 24 \times 100 \times 100 = 240,000 \text{ bits} [/latex]

Then suppose we run K-Means with [latex]K=16[/latex] clusters. We will get [latex]16[/latex] cluster centroids, each of which is a [latex]3[/latex]-dimensional vector. This requires us to store [latex]16 \times 3 = 48[/latex] numbers, each of which is represented with 8 bits of precision. This leads to a total of [latex]16 \times 3 \times 8 = 384[/latex] bits to store the cluster centroids.

Next, we also need to store the mapping of each pixel in the image to a cluster centroid. This is an [latex]N \times 1[/latex] vector, and for each pixel, we only store a single number ranging from 0 to 15. This means that we will need [latex]\lceil \log_2 16 \rceil = 4[/latex] bits to represent the index of the cluster centroid to which a pixel is assigned. This amounts to a total of [latex]10,000 \times 4 = 40,000[/latex] bits to store the mapping of each pixel to a cluster centroid.

Thus, the total number of bits required to store the compressed image is

[latex] 40,000 + 384 = 40,384 \text{ bits} [/latex]

compared to the original image size of [latex]240,000[/latex] bits. This results in a compression ratio of

[latex]40,384 / 240,000 = 16.8\%[/latex]

### Step-by-step code implementation

In this section, we will go through the process of applying K-Means for compressing images. Specifically, we will use a hestain^{1} image, a type of tissue image stained with hematoxylin and eosin (H&E). Pathologists utilize this staining technique to differentiate between tissue types that are colored blue-purple and pink.

#### Load and read the Image

Let's load the image into memory first.

```
image_path = "https://storage.googleapis.com/reighns/images/hestain.png"
image = np.array(PIL.Image.open(urlopen(image_path)).convert("RGB"))
image_shape = image.shape
print(f"Image shape: {image_shape}")
print(f"Image dtype: {image.dtype}") # uint8
```

```
>>> Image shape: (227, 303, 3)
>>> Image dtype: uint8
```

Here are the steps for the code:

- Define the image path as a URL,
`"https://storage.googleapis.com/reighns/images/hestain.png"`

. - Convert the image into a NumPy array using the
`PIL`

library, by opening the image using`PIL.Image.open`

method and converting it into an RGB format using the`convert`

method. - Store the shape of the image as a tuple in the variable
`image_shape`

. - We also print out the image data type.

#### Size of the Image

We use the function `calculate_num_bits`

defined earlier to calculate how many bits this image takes.

```
_, image_size_in_bits = calculate_num_bits(image, 255)
print(f"Image size in bits: {image_size_in_bits}")
```

`>>> Image size in bits: 1650744`

We can verify this number via the formula earlier: [latex]227 \times 303 \times 24 = 1,650,744[/latex]

**Apply **Compression via k-means

In what follows, we will write a function `compress_image`

that takes in an image represented as a NumPy array `image`

and other optional parameters as a dictionary `kmeans_kwargs`

, which are passed to the K-Means algorithm.

```
def compress_image(
image: np.ndarray, **kmeans_kwargs: Dict[str, Any]
) -> Tuple[np.ndarray, np.ndarray]:
pixels = image.reshape((image.shape[0] * image.shape[1], 3))
kmeans = KMeans(**kmeans_kwargs)
kmeans.fit(pixels)
compressed_image = kmeans.labels_.astype(np.uint8) # y_preds and convert to uint8
centroids = kmeans.cluster_centers_ # total bits = 3 * 8 * K
return compressed_image, centroids
```

The function performs the following steps:

- Reshapes the image from
`(height, width, depth)`

to`(height * width, depth)`

to convert it into a 2D array, where each row is a pixel represented by its RGB values. We assign the reshaped image as a variable named`pixels`

. - Instantiate the K-Means algorithm with the given
`kmeans_kwargs`

and fit the K-Means model on the pixel data. - Extract the predicted cluster labels for each pixel and name it
`compressed_image`

. - Extract the predicted centroids (mean values) of each cluster and name it
`centroids`

. - Return
`compressed_image`

and`centroids`

. The former has the shape of`(N, 1)`

, where N is the number of pixels in the image, and the latter has the shape of`(K, 3)`

, where`K`

is the number of clusters. Note that`compressed_image`

is nothing but a 1D array of cluster labels and`centroids`

is a 2D array of RGB values of the cluster centroids (the mean of each cluster).

Let's run the code with `K=2`

on the image.

```
K = 2
compressed_image, centroids = compress_image(
image, n_clusters=K, n_init="auto", max_iter=300, random_state=42
)
```

Let's see a comparison of the size of the original image, compressed image, centroid, total compressed size, and compression ratio in the table below.

Metric | Value |
---|---|

Original size | 1650744 bits |

Compressed Image size | 68781 bits |

Centroids size | 48 bits |

Compressed size | 68829 bits |

Compression ratio | 0.04 (~4 percent) |

We see that we managed to reduce our storage by approximately [latex]96\%[/latex]. Let's see how the compressed image looks by reconstructing it.

#### Reconstruction

In this section, we will write a function `reconstruct_image`

that takes in the compressed image represented as a NumPy array `compressed_image`

, the `centroids`

that was learned from the K-Means algorithm, and `original_shape`

, which is the shape of the original image.

```
def reconstruct_image(
compressed_image: np.ndarray,
centroids: np.ndarray,
original_shape: Tuple[int, int, int],
) -> np.ndarray:
reconstructed_image = centroids[compressed_image]
reconstructed_image = reconstructed_image.reshape(original_shape).astype(np.uint8)
return reconstructed_image
```

`compressed_image`

: A NumPy array of shape`(N, 1)`

representing the compressed image, where`N`

is the number of pixels in the original image.`centroids`

: A NumPy array of shape`(K, 3)`

representing the cluster centroids obtained after performing K-Means on the original image.`original_shape`

: A tuple representing the shape of the original image, in the format`(height, width, depth)`

.

- It uses the
`compressed_image`

array to index into the`centroids`

array, effectively replacing each pixel value in`compressed_image`

with its corresponding cluster centroid. - It then reshapes the resulting array to match the original shape of the image.
- Finally, it returns the reconstructed image as a NumPy array of unsigned 8-bit integers.

`reconstruct_image`

function takes a compressed image represented as a 1D array of cluster labels and the RGB values of the cluster centroids and returns the original image in its 3D NumPy array representation.`reconstructed_image = reconstruct_image(compressed_image, centroids, image.shape)`

Comparing the original image and the reconstructed image

We can clearly see that with [latex]K=2[/latex] clusters, the image is dichotomized into two distinct segments. This is quite a coarse representation of the original image.

Let's run the compression technique a few more times, this time with [latex]K=2, 3, 6[/latex] and [latex]16[/latex] and observe the results.

As we can see, the quality of the compressed image will depend on the number of clusters used. In general, a larger number of clusters will result in higher image quality, but at the expense of a larger file size.

## Image Segmentation

Image compression using K-Means can be viewed as a form of image segmentation in the RGB space. Why is that the case? Let's first look at the definition of image segmentation.

Image segmentation is the process of partitioning a digital image into multiple segments or regions, each of which corresponds to a separate object or part of the image.

Extracted from Wikipedia, Image Segmentation

Looking back at our K-Means algorithm, it divides the image into segments based on the similarities of the colors of the pixels in the image. In other words, this coincides with partitioning the image into [latex]K[/latex] colors, defined by each centroid.

Consequently, this results in segmentation of the image via the color (RGB) space.

One should however note that this method of segmentation **does not** take into account the spatial geometry of neighboring pixels, and thus result in a less meaningful representation of images. Let's see why.

Although K-Means does not assume any underlying probability distribution (unless you view it as Gaussian Mixture Model), we can still view the clustering method as treating each pixel as an independent data point and grouping them together solely based on their color values. As a result, neighboring pixels with different colors may be assigned to different clusters or segments, leading to a loss of spatial coherence in the resulting segmentation or compression. Therefore, if you think that segmenting an image based on the color space is not the aim, you might need to explore other methods. Modern deep learning tends to handle it better with more fine-grained segments.

## Conclusion

In this article, we covered how we can utilize K-Means as a way to compress images through the concept of vector quantization. We further link how the images can be segmented into different color spaces as a consequence of performing the clustering algorithm.

K-Means has many other applications, for example, in the context of Natural Language Processing (NLP), you can use it to cluster documents as well.

For a more formal treatment of the K-Means algorithm as a whole, one can refer to the references section. I have also written a more detailed post on K-Means here, which touches on the formulation of this algorithm.

## References

- Murphy, Kevin P. "Chapter 21.3. K-Means Clustering." In Probabilistic Machine Learning: An Introduction. MIT Press, 2022.
- Bishop, Christopher M. "Chapter 9.1. K-Means Clustering." In Pattern Recognition and Machine Learning. New York: Springer-Verlag, 2016.

## Footnotes

- Information from Mathworks.