1 Introduction

This report explores Principal Component Analysis (PCA), a method introduced during the course, as a simple and transparent approach to image compression. The idea is to represent an image with fewer degrees of freedom while keeping the reconstruction visually acceptable.

Two settings are considered: - PCA applied to a grayscale version of the image, - PCA applied separately to each RGB channel.

The report focuses on the practical trade-off between compression strength and visual quality, as observed both numerically and visually. Reconstruction quality is evaluated using MSE and PSNR, and the results are compared against standard JPEG compression at different quality settings.

Image source. The original image of Warsaw used in this study was obtained from the European Space Agency (ESA) Earth observation portal. The image originates from the “Earth from Space” series and is available at: https://www.esa.int/Applications/Observing_the_Earth/Earth_from_Space_Warsaw_Poland


2 Setup and Libraries

We start by loading the required libraries and setting global chunk options.


3 Helper Functions

To make the workflow easier to follow, I define a few small helper functions:

clip01 <- function(x) pmin(pmax(x, 0), 1)

psnr01 <- function(orig, rec, eps = 1e-12) {
  mse_val <- mean((orig - rec)^2)
  10 * log10(1 / (mse_val + eps))
}

k_for_thresh <- function(pca, thr = 0.90) {
  cum <- cumsum(pca$sdev^2) / sum(pca$sdev^2)
  which(cum >= thr)[1]
}

best_under_budget <- function(tbl, budget) {
  cand <- tbl[tbl$size_bytes <= budget, ]
  if (nrow(cand) == 0) return(NULL)
  cand[which.max(cand$PSNR), ]
}

4 Loading and Inspecting the Image

The original RGB image is loaded and displayed.
Basic statistics help understand pixel value ranges.

img <- readJPEG("Warsaw_Poland_High.jpg")

plot(as.raster(img))
title("Original image")

dim(img)
## [1] 1575 2362    3
summary(img)
##    Min. 1st Qu.  Median    Mean 3rd Qu.    Max. 
##  0.0000  0.1255  0.2314  0.2773  0.3882  1.0000

5 Grayscale Conversion

Two grayscale conversions were considered: a simple RGB average and a luminance-based conversion. In this report, the luminance weights (0.299, 0.587, 0.114) are used, which is a common approximation of perceived brightness and typically produces a more faithful grayscale image.

img_gray <- 0.299*img[,,1] + 0.587*img[,,2] + 0.114*img[,,3]

plot(as.raster(img_gray))
rasterImage(img_gray, 0, 0, 1, 1)
title("Grayscale image (luminance)")

writeJPEG(img_gray, "warsaw-gray.jpg")

6 PCA Applied to RGB Channels

PCA is performed separately for each RGB channel. Each channel is treated as an \(n \times m\) matrix (image rows as observations and columns as variables), and PCA is applied to that matrix.

Keeping channels separate avoids mixing color information between RGB components. With a limited number of components, the reconstruction mainly preserves large-scale structures, while finer textures and details require a higher number of components.

r <- img[,,1]
g <- img[,,2]
b <- img[,,3]

r.pca <- prcomp(r, center = FALSE, scale. = FALSE)
g.pca <- prcomp(g, center = FALSE, scale. = FALSE)
b.pca <- prcomp(b, center = FALSE, scale. = FALSE)

rgb.pca <- list(r.pca, g.pca, b.pca)

6.1 Explained Variance

The explained-variance plots drop quickly for the first components (in all three channels), and then flatten out as later components mainly add smaller details. This indicates that a relatively small number of principal components captures a large fraction of the variability in the image, while later components mostly refine smaller-scale details.

In practice, this kind of spectrum explains why PCA can be useful for compression in this setting. The early components preserve most large structures, and increasing the number of components gradually brings back texture and fine features.

f1 <- fviz_eig(r.pca, main = "Red",   barfill = "red",   ncp = 5, addlabels = TRUE)
f2 <- fviz_eig(g.pca, main = "Green", barfill = "green", ncp = 5, addlabels = TRUE)
f3 <- fviz_eig(b.pca, main = "Blue",  barfill = "blue",  ncp = 5, addlabels = TRUE)

grid.arrange(f1, f2, f3, ncol = 3)


7 Automatic Selection of Components

We compute how many principal components are required to explain 90% of variance.

thr <- 0.90
kR <- k_for_thresh(r.pca, thr)
kG <- k_for_thresh(g.pca, thr)
kB <- k_for_thresh(b.pca, thr)

c(R = kR, G = kG, B = kB)
##  R  G  B 
## 88 38 88
k_common <- ceiling(mean(c(kR, kG, kB)))
k_common
## [1] 72

8 Image Reconstruction Using PCA

Images are reconstructed using different numbers of principal components.
This illustrates the trade-off between compression level and visual quality.

vec <- round(seq(3, nrow(img), length.out = 9))
vec
## [1]    3  200  396  592  789  986 1182 1378 1575
for (k in vec) {
  rec <- sapply(rgb.pca, function(p) {
    p$x[, 1:k] %*% t(p$rotation[, 1:k])
  }, simplify = "array")

  rec <- clip01(rec)
  assign(paste0("photo_", k), rec)
  writeJPEG(rec, paste0("photo_", k, "_princ_comp.jpg"))
}

8.1 Grid of reconstructions

par(mfrow = c(3, 3), mar = c(0.5, 0.5, 3, 0.5))

for (k in vec) {
  im <- image_read(get(paste0("photo_", k)))
  plot(im)
  title(paste("PCs =", k), cex.main = 1.1)
}

par(mfrow = c(1, 1))

9 Quality Metrics for PCA Reconstructions

This distinction helps separate the practical file size from the theoretical cost of storing the PCA representation, which was discussed during the course.

For each reconstruction we report: - the JPEG file size of the reconstructed image (i.e., after saving the PCA reconstruction as JPEG), - reconstruction quality (MSE, PSNR) measured against the original image, - a simple theoretical PCA storage estimate, i.e., how many numeric values would be needed to store the PCA representation itself (scores + loadings) if we were not using JPEG at all.

This separates two notions of “size”: practical JPEG size vs the theoretical size of the PCA model.

n <- dim(img)[1]
m <- dim(img)[2]
orig_values <- 3 * n * m

sizes <- data.frame(
  PCs_per_channel = vec,
  JPEG_size_bytes = NA_real_,
  MSE = NA_real_,
  PSNR = NA_real_,
  PCA_values_to_store = NA_real_,
  PCA_ratio_vs_original = NA_real_
)

for (i in seq_along(vec)) {
  k <- vec[i]
  path <- paste0("photo_", k, "_princ_comp.jpg")

  sizes$JPEG_size_bytes[i] <- file.info(path)$size

  rec <- readJPEG(path)
  sizes$MSE[i]  <- mean((img - rec)^2)
  sizes$PSNR[i] <- psnr01(img, rec)

  # Theoretical PCA storage (RGB): 3 * k * (n + m) values
  store_vals <- 3 * k * (n + m)
  sizes$PCA_values_to_store[i] <- store_vals
  sizes$PCA_ratio_vs_original[i] <- store_vals / orig_values
}

sizes$MSE <- signif(sizes$MSE, 4)
sizes$PSNR <- round(sizes$PSNR, 2)
sizes$PCA_ratio_vs_original <- round(sizes$PCA_ratio_vs_original, 4)

sizes
##   PCs_per_channel JPEG_size_bytes       MSE  PSNR PCA_values_to_store
## 1               3          210002 0.0268300 15.71               35433
## 2             200          780268 0.0056730 22.46             2362200
## 3             396          915309 0.0028460 25.46             4677156
## 4             592          982313 0.0017210 27.64             6992112
## 5             789         1025447 0.0011370 29.44             9318879
## 6             986         1067397 0.0007596 31.19            11645646
## 7            1182         1097580 0.0005562 32.55            13960602
## 8            1378         1104574 0.0005090 32.93            16275558
## 9            1575         1104643 0.0005049 32.97            18602325
##   PCA_ratio_vs_original
## 1                0.0032
## 2                0.2117
## 3                0.4191
## 4                0.6265
## 5                0.8350
## 6                1.0435
## 7                1.2509
## 8                1.4583
## 9                1.6668

9.1 Plots

As expected, reconstruction error drops quickly when moving from very small k to moderate values. After that, improvements become gradual: additional components mainly recover fine textures rather than large structures.

PSNR increases accordingly, while the JPEG size of the reconstructed image tends to grow as more detail is preserved (the reconstructed image becomes harder to compress).

par(mfrow = c(1, 3), mar = c(4, 4, 2, 1))

plot(sizes$PCs_per_channel, sizes$MSE, type = "b",
     xlab = "PCs per channel", ylab = "MSE", main = "PCA+JPEG quality (MSE)")

plot(sizes$PCs_per_channel, sizes$PSNR, type = "b",
     xlab = "PCs per channel", ylab = "PSNR [dB]", main = "PCA+JPEG quality (PSNR)")

plot(sizes$PCs_per_channel, sizes$JPEG_size_bytes, type = "b",
     xlab = "PCs per channel", ylab = "JPEG size [B]", main = "PCA+JPEG file size")

par(mfrow = c(1, 1))

10 Baseline JPEG Compression

For comparison, the original image is compressed using standard JPEG at different quality levels.

qualities <- c(0.1, 0.3, 0.5, 0.7, 0.9)

jpeg_base <- data.frame(
  quality = qualities,
  size_bytes = NA_real_,
  MSE = NA_real_,
  PSNR = NA_real_
)

for (i in seq_along(qualities)) {
  q <- qualities[i]
  fname <- paste0("jpeg_q", q, ".jpg")

  writeJPEG(img, target = fname, quality = q)

  jpeg_base$size_bytes[i] <- file.info(fname)$size
  rec <- readJPEG(fname)
  jpeg_base$MSE[i]  <- mean((img - rec)^2)
  jpeg_base$PSNR[i] <- psnr01(img, rec)
}

jpeg_base$MSE  <- signif(jpeg_base$MSE, 4)
jpeg_base$PSNR <- round(jpeg_base$PSNR, 2)

jpeg_base
##   quality size_bytes       MSE  PSNR
## 1     0.1     236163 5.353e-03 22.71
## 2     0.3     489227 2.750e-03 25.61
## 3     0.5     661066 1.827e-03 27.38
## 4     0.7    1104643 5.049e-04 32.97
## 5     0.9    1709800 1.112e-05 49.54

11 PCA vs JPEG Comparison (Single Table + Pareto Front)

The combined table compares standard JPEG compression with JPEG files obtained after PCA reconstruction (“PCA+JPEG”). Note that “PCA+JPEG” refers to saving the PCA reconstruction as a JPEG file. This evaluates how well PCA preserves structure before JPEG encoding, rather than comparing PCA as a standalone codec against JPEG. Sorting by file size and PSNR allows a direct efficiency comparison.

The Pareto front highlights configurations for which improving PSNR would require increasing the file size. In this experiment, JPEG tends to be very strong at extreme compression levels, while PCA-based reconstructions can be competitive in some mid-size ranges, depending on the number of retained components.

pca_tbl <- data.frame(
  method = "PCA+JPEG",
  param  = paste0("k=", sizes$PCs_per_channel),
  size_bytes = sizes$JPEG_size_bytes,
  PSNR = sizes$PSNR
)

jpeg_tbl <- data.frame(
  method = "JPEG",
  param  = paste0("q=", jpeg_base$quality),
  size_bytes = jpeg_base$size_bytes,
  PSNR = jpeg_base$PSNR
)

comp_all <- rbind(pca_tbl, jpeg_tbl)
comp_all <- comp_all[order(comp_all$size_bytes, -comp_all$PSNR), ]

# Pareto front
comp_all$pareto <- FALSE
best_psnr_so_far <- -Inf
for (i in seq_len(nrow(comp_all))) {
  if (comp_all$PSNR[i] > best_psnr_so_far) {
    comp_all$pareto[i] <- TRUE
    best_psnr_so_far <- comp_all$PSNR[i]
  }
}

comp_all
##      method  param size_bytes  PSNR pareto
## 1  PCA+JPEG    k=3     210002 15.71   TRUE
## 10     JPEG  q=0.1     236163 22.71   TRUE
## 11     JPEG  q=0.3     489227 25.61   TRUE
## 12     JPEG  q=0.5     661066 27.38   TRUE
## 2  PCA+JPEG  k=200     780268 22.46  FALSE
## 3  PCA+JPEG  k=396     915309 25.46  FALSE
## 4  PCA+JPEG  k=592     982313 27.64   TRUE
## 5  PCA+JPEG  k=789    1025447 29.44   TRUE
## 6  PCA+JPEG  k=986    1067397 31.19   TRUE
## 7  PCA+JPEG k=1182    1097580 32.55   TRUE
## 8  PCA+JPEG k=1378    1104574 32.93   TRUE
## 9  PCA+JPEG k=1575    1104643 32.97   TRUE
## 13     JPEG  q=0.7    1104643 32.97  FALSE
## 14     JPEG  q=0.9    1709800 49.54   TRUE

11.1 Pareto-only table

The Pareto-only table summarizes the most efficient configurations across both compression approaches. Each listed configuration represents a non-dominated solution, meaning that improving image quality would necessarily require increasing the file size.

The presence of both PCA-based and JPEG-based methods on the Pareto front suggests that neither approach dominates across the entire size range in this particular experiment. In practice, the preferred method depends on the target file size and the acceptable quality level.

pareto_tbl <- comp_all[comp_all$pareto, ]
pareto_tbl
##      method  param size_bytes  PSNR pareto
## 1  PCA+JPEG    k=3     210002 15.71   TRUE
## 10     JPEG  q=0.1     236163 22.71   TRUE
## 11     JPEG  q=0.3     489227 25.61   TRUE
## 12     JPEG  q=0.5     661066 27.38   TRUE
## 4  PCA+JPEG  k=592     982313 27.64   TRUE
## 5  PCA+JPEG  k=789    1025447 29.44   TRUE
## 6  PCA+JPEG  k=986    1067397 31.19   TRUE
## 7  PCA+JPEG k=1182    1097580 32.55   TRUE
## 8  PCA+JPEG k=1378    1104574 32.93   TRUE
## 9  PCA+JPEG k=1575    1104643 32.97   TRUE
## 14     JPEG  q=0.9    1709800 49.54   TRUE

12 Best Method Under Size Budgets (Adjusted Automatically)

To make the comparison more practical, several file-size budgets were selected automatically. For each budget, the configuration with the highest PSNR was chosen separately for PCA-based reconstructions (“PCA+JPEG”) and standard JPEG.

At very small budgets, JPEG typically achieves better PSNR. For intermediate budgets, PCA reconstructions with a moderate number of components can sometimes be competitive, while for large budgets both approaches approach high quality.

minB <- min(comp_all$size_bytes)
maxB <- max(comp_all$size_bytes)

budgets <- round(seq(minB, maxB, length.out = 6), -3)
budgets <- sort(unique(budgets))
budgets
## [1]  210000  510000  810000 1110000 1410000 1710000
budget_rows <- lapply(budgets, function(B) {
  best_pca  <- best_under_budget(pca_tbl,  B)
  best_jpeg <- best_under_budget(jpeg_tbl, B)

  data.frame(
    budget_bytes = B,
    best_PCA_param = if (!is.null(best_pca)) best_pca$param else NA,
    best_PCA_size  = if (!is.null(best_pca)) best_pca$size_bytes else NA,
    best_PCA_PSNR  = if (!is.null(best_pca)) best_pca$PSNR else NA,
    best_JPEG_param= if (!is.null(best_jpeg)) best_jpeg$param else NA,
    best_JPEG_size = if (!is.null(best_jpeg)) best_jpeg$size_bytes else NA,
    best_JPEG_PSNR = if (!is.null(best_jpeg)) best_jpeg$PSNR else NA
  )
})

budget_tbl <- do.call(rbind, budget_rows)
budget_tbl
##   budget_bytes best_PCA_param best_PCA_size best_PCA_PSNR best_JPEG_param
## 1       210000           <NA>            NA            NA            <NA>
## 2       510000            k=3        210002         15.71           q=0.3
## 3       810000          k=200        780268         22.46           q=0.5
## 4      1110000         k=1575       1104643         32.97           q=0.7
## 5      1410000         k=1575       1104643         32.97           q=0.7
## 6      1710000         k=1575       1104643         32.97           q=0.9
##   best_JPEG_size best_JPEG_PSNR
## 1             NA             NA
## 2         489227          25.61
## 3         661066          27.38
## 4        1104643          32.97
## 5        1104643          32.97
## 6        1709800          49.54

13 Conclusions

This study shows that PCA can be used as a simple and interpretable compression mechanism for images: with relatively few components, large-scale structures are preserved, while fine textures require more components.

Quantitatively, MSE decreases and PSNR increases quickly for small-to-moderate numbers of components, and then improvements become incremental. This matches the visual impression from the reconstruction grid.

In the direct comparison, standard JPEG remains a very strong baseline, especially at extreme compression levels. PCA-based reconstructions (“PCA+JPEG”) can be competitive in some mid-range file sizes, but overall, PCA works well as an exploratory dimensionality-reduction tool in this context, but it does not replace specialized image compression codecs such as JPEG.

From a practical perspective, visual inspection of the reconstructions was often more informative than numerical metrics alone, especially when comparing mid-range compression levels.