Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

Preview: Spatial Coherence and Change of Basis

stough, 202-

Complicated way of saying it: Image compression relies on spatial coherence of image data. The fact that a pixel’s value is highly correlated with its neighbors most of the time, means that changes of basis like the below can lead to a much more compressible coordinate representation (more zeros, or minimum description length)...

Little simpler: We are all familiar with plotting points in the plane.

XY-plane

A point AA for example might have the coordinates ⟨4,3⟩\langle4,3\rangle, by which we mean 4⋅⟨1,0⟩+3⋅⟨0,1⟩4\cdot\langle1,0\rangle + 3\cdot\langle0,1\rangle, or 4 in the xx direction and 3 in the yy. But the same point in space can also be represented as for example 3.5⋅⟨1,1⟩+0.5⋅⟨1,−1⟩3.5\cdot\langle1,1\rangle + 0.5\cdot\langle1,-1\rangle, or rather ⟨72,12⟩\langle\frac{7}{\sqrt2}, \frac{1}{\sqrt2}\rangle if we insist on the two directions we’re measuring in (⟨1,1⟩,⟨1,−1⟩\langle1,1\rangle,\langle1,-1\rangle) to be unit length.

If we think about the magnitude or energy of the point, or distance from the origin, notice that in this alternative representation, much of that magnitude (78\frac{7}{8} th of it in fact) is accounted for by the first component. In fact for a lot of points for which the xx and yy components are nearly the same, the second component could potentially be ignored without losing too much. This is where the compression will come from: if we can ignore the coefficient with some minimal loss, then we don’t have to store or transmit it.

In this demo we’re going to try to demonstrate how such a change of basis can improve the compressibility of image data. We’ll look at a pixel and its neighbor, over all pairs in an image. Spatial coherence implies that most of the time, a pixel and its neighbor will be similar.

((512, 512, 4), dtype('float32'), np.float32(0.0), np.float32(1.0))
<Figure size 400x300 with 1 Axes>
<Figure size 400x300 with 1 Axes>

 

Let’s look at pixel pairs

We’ll just consider one of the color channels so as not to confuse the issue more.

((131072, 2), dtype('float32'), np.float32(0.0), np.float32(0.9882353))
array([[0.76862746, 0.78039217], [0.78039217, 0.7764706 ], [0.7764706 , 0.78431374], [0.78431374, 0.78039217], [0.78431374, 0.76862746], [0.67058825, 0.49803922], [0.27058825, 0.14901961], [0.14117648, 0.14117648], [0.15294118, 0.14509805], [0.14509805, 0.15686275]], dtype=float32)
<Figure size 640x480 with 1 Axes>

It looks like neighboring pixels are highly correlated with one another. This is the essence of spatial coherence. Rather than considering them as independent pieces of information, we can instead reframe how we represent a pixel pair.

 

Changing the basis of a pixel pair

If we want to reframe every pixel pair to take advantage of spatial coherence, we can move from the independent basis {⟨1,0⟩,⟨0,1⟩}\{\langle1,0\rangle,\langle0,1\rangle\} to a different independent basis like {12⟨1,1⟩,12⟨1,−1⟩}\{\frac{1}{\sqrt2}\langle1,1\rangle,\frac{1}{\sqrt2}\langle1,-1\rangle\}. Rather than thinking of the pixel pair as “this pixel, then that pixel”, we’ll have something like “the average of the pair, then the difference of the pair.” If spatial coherence is as prevelent as the above scatter plot implies, then “the difference of the pair” will be a relatively insignificant part of the typical pair.

To transform the normal representation of a pixel pair ⟨pix0,pix1⟩\langle pix_0,pix_1\rangle to the new basis, we can multiply it by a matrix of the two new basis vectors:

12∣111−1∣∣pix0pix1∣=12∣pix0+pix1pix0−pix1∣=∣c0c1∣\frac{1}{\sqrt2}\begin{vmatrix} 1 & 1 \\ 1 & -1 \end{vmatrix} \begin{vmatrix} pix_0 \\ pix_1 \end{vmatrix} = \frac{1}{\sqrt2}\begin{vmatrix} pix_0 + pix_1 \\ pix_0 - pix_1 \end{vmatrix} = \begin{vmatrix} c_0 \\ c_1 \end{vmatrix}

You can check this with respect to the pixel pair ⟨4,3⟩\langle4,3\rangle noted at the top. Below we’ll call this representation avg/diff. (It’s also the first steps to the Haar wavelet basis.)

array([[ 0.70710678, 0.70710678], [ 0.70710678, -0.70710678]])
((131072, 2), dtype('float64'), np.float64(-0.49913420034404155), np.float64(1.3948027891445542))
array([ 9.02026491e-01, -1.25878487e-05])

When we compute the average coefficient in the avg versus the diff dimensions, we can see that on average, the first coefficient has much more power. Again, this is expected given that we saw above how correlated neighboring pixel values are.

 

Let’s see how much energy is in each component for each pair.

We would like to understand what percentage of the total is in each component in this new representation. We want each pair to sum to 1, where the components are positive.

array([[9.92462312e-01, 7.53768787e-03], [9.97487437e-01, 2.51256262e-03], [9.95000000e-01, 4.99999962e-03], [9.97500000e-01, 2.49999981e-03], [9.90000001e-01, 9.99999924e-03], [8.71345018e-01, 1.28654982e-01], [7.75362316e-01, 2.24637684e-01], [1.00000000e+00, 6.72321969e-18], [9.74358977e-01, 2.56410231e-02], [9.62500004e-01, 3.74999964e-02]])
array([[1.], [1.], [1.], [1.], [1.], [1.], [1.], [1.], [1.], [1.]])

You can see that, at least for the first ten pixel pairs, that in this new representation the energy in the 12⟨1,1⟩\frac{1}{\sqrt2}\langle1,1\rangle direction is most of the total. Let’s look at a histogram of these two components, to see if this trend holds across the whole image.

<Figure size 640x480 with 1 Axes>

It looks like in the new representation, the c0c_0 component, associated with the 12⟨1,1⟩\frac{1}{\sqrt2}\langle1,1\rangle direction, has almost all the power/energy/magnitude associated with any pair. How does this compare to the original representation?

<Figure size 640x480 with 1 Axes>

The above plot shows that when we consider a pair as two indpendent pixels, then each component (pixipix_i, and pixi+1pix_{i+1}) is equally important.

 

What if we zeroed out all of that less important dimension?

Given that the c1c_1 components seem to matter so little, what if we just didn’t keep them? Would that change the image all that much? Once we’ve zeroed out the c1c_1 components, then going back to the pixel space is a matter of multiplying in the other direction:

∣c0c1∣⋅12∣111−1∣=12∣pix0+pix1pix0−pix1∣⋅12∣111−1∣=12∣2pix02pix1∣=∣pix0pix1∣\begin{vmatrix} c_0 & c_1 \end{vmatrix} \cdot \frac{1}{\sqrt2}\begin{vmatrix} 1 & 1 \\ 1 & -1 \end{vmatrix} = \frac{1}{\sqrt2}\begin{vmatrix} pix_0 + pix_1 & pix_0 - pix_1 \end{vmatrix} \cdot \frac{1}{\sqrt2}\begin{vmatrix} 1 & 1 \\ 1 & -1 \end{vmatrix} = \frac{1}{2}\begin{vmatrix} 2pix_0 & 2pix_1 \end{vmatrix} = \begin{vmatrix} pix_0 & pix_1 \end{vmatrix}

You can check with respect to the coefficient representation ⟨72,12⟩\langle\frac{7}{\sqrt2}, \frac{1}{\sqrt2}\rangle from above.

((131072, 2), dtype('float64'), np.float64(0.003921568859368562), np.float64(0.9862745106220244))
<Figure size 800x300 with 2 Axes>

It appears that eliminating half of the coefficients (all of the c1c_1’s) has a neglible effect on the image. So, 50% compression with negligible fidelity loss.

How does this compare to eliminating one of the pixels in every pixel pair in the original representations?

<Figure size 800x300 with 3 Axes>

In the above we see that eliminating half of the coefficients in the pixel space leads to more noticeable degradation than doing so in the avg/diff representation. Due to spatial coherence, the avg/diff space usually accounts for most of the energy of a pixel pair in only the “avg” component. Such a change of basis followed by truncating or zeroing out components is the basis for lossy compression schemes like JPEG.

Little experiment figuring out how to consider a pixel and its horizontal neighbor.

array([[ 0, 1, 2, 3], [ 4, 5, 6, 7], [ 8, 9, 10, 11], [12, 13, 14, 15]])
array([[ 0, 1], [ 2, 3], [ 4, 5], [ 6, 7], [ 8, 9], [10, 11], [12, 13], [14, 15]])
array([[ 0, 1], [ 2, 3], [ 4, 5], [ 6, 7], [ 8, 9], [10, 11], [12, 13], [14, 15]])
array([[ 0, 1], [ 2, 3], [ 4, 5], [ 6, 7], [ 8, 9], [10, 11], [12, 13], [14, 15]])

 

Looking Ahead: the 2D Haar Transform on Image Blocks

We’ll see more on this later, but it’s worth mentioning now.

The 1D average/difference decomposition we’ve explored is the foundation of the 2D Haar wavelet transform. Here’s how it extends to 2D. Instead of a horizontal pair of pixels, take a 2×22 \times 2 block

[p00p01p10p11]\begin{bmatrix} p_{00} & p_{01} \\ p_{10} & p_{11} \end{bmatrix}

and apply the same average/difference transform HH to its rows (the horizontal direction), and then to the columns of the result (the vertical direction). This produces four coefficients, each capturing a different kind of information:

  • LL (average in both directions): the block’s overall brightness. Taken over the whole image, the LL coefficients form a half-resolution copy of it.

  • LH (average along the rows, difference down the columns): responds where the intensity changes from top to bottom, that is, at horizontal edges.

  • HL (difference along the rows, average down the columns): responds to vertical edges.

  • HH (difference in both directions): diagonal detail and fine texture.

(Texts differ on which of the two mixed coefficients they call LH and which HL.) Because of spatial coherence, most of the image’s energy concentrates in the LL coefficients, while the other three are mostly near zero, so they can be coarsely quantized or discarded with little visible loss. Repeating the process on the LL image gives a multi-level decomposition. That is the idea behind wavelet compression schemes such as JPEG 2000. (The original JPEG uses a different transform, the discrete cosine transform, on 8×88 \times 8 blocks.) We work through all of this in Haar Wavelets.