Unshredding an Image with One Eigenvector
Math 123: Mathematical Aspects of Data Analysis — Fall 2026
Cut a photograph into single columns of pixels, shuffle the columns, and hand them back: can we put them in order again? Neighbouring columns of a photograph look almost the same, so the columns are points lying along a curve in a high-dimensional space, and the order we want is position along that curve. A one-dimensional Laplacian eigenmap gives each column a single number, and sorting the columns by that number puts them back in order. Using an eigenvector to put things in order, instead of to cluster or to plot them, is called seriation.
Figure style and image helpers
INK = "#7c7c78" # muted colors that read in light and dark mode
GRID = "rgba(128, 128, 128, 0.28)"
BLUE, ORANGE = "#2a78d6", "#eb6834"
def style(fig, height):
fig.update_layout(template="simple_white", paper_bgcolor="rgba(0,0,0,0)",
plot_bgcolor="rgba(0,0,0,0)", font=dict(color=INK, size=13),
height=height, margin=dict(l=60, r=20, t=30, b=50), hovermode="closest")
fig.update_xaxes(gridcolor=GRID, linecolor=GRID, zerolinecolor=GRID)
fig.update_yaxes(gridcolor=GRID, linecolor=GRID, zerolinecolor=GRID)
return fig
def to_rgba(image):
"""An H x W (grey) or H x W x 3 (colour) image in [0, 1] as opaque RGBA bytes."""
rgb = np.repeat(image[..., None], 3, axis=2) if image.ndim == 2 else image
alpha = np.ones(rgb.shape[:2] + (1,))
return (np.clip(np.concatenate([rgb, alpha], axis=2), 0, 1) * 255).astype(np.uint8)
def image_uri(rgba):
"""Encode RGBA bytes as a data URI: JPEG for opaque photographs, PNG otherwise.
An image is far smaller than the same picture as a heatmap of numbers.
"""
buffer = io.BytesIO()
if rgba[..., 3].min() == 255:
Image.fromarray(rgba[..., :3], "RGB").save(buffer, "JPEG", quality=90)
kind = "jpeg"
else:
Image.fromarray(rgba, "RGBA").save(buffer, "PNG")
kind = "png"
return f"data:image/{kind};base64," + base64.b64encode(buffer.getvalue()).decode()
def image_figure(images, labels, height, gap=16):
"""Show RGBA images of equal height side by side, with a label under each."""
fig, left, centres = go.Figure(), 0, []
for image in images:
fig.add_trace(go.Image(source=image_uri(image), x0=left, hoverinfo="skip"))
centres.append(left + (image.shape[1] - 1) / 2)
left += image.shape[1] + gap
fig.update_xaxes(tickvals=centres, ticktext=labels, showgrid=False, zeroline=False,
showline=False, ticks="")
fig.update_yaxes(showticklabels=False, showgrid=False, zeroline=False, showline=False)
return style(fig, height)
def adjacency_image(W):
"""Draw each edge of W as a small ink-coloured square on a faint grey background."""
rgba = np.empty(W.shape + (4,), dtype=np.uint8)
rgba[:] = (128, 128, 128, 30)
rgba[binary_dilation(W > 0, np.ones((3, 3)))] = (124, 124, 120, 255)
return rgbaThe shredded photograph
We use the cat from skimage.data, a \(300\times451\) colour photograph. Each column is \(300\) pixels with three colour channels, so it is a vector in \(\Rbb^{900}\), and the image is \(451\) points in \(\Rbb^{900}\). We shuffle the columns with a random permutation. From here on the method only sees shredded; we keep perm only to check the answer at the end.
photo = data.chelsea() / 255 # (300, 451, 3), brightness in [0, 1]
n_rows, n_cols, _ = photo.shape
rng = np.random.default_rng(0)
perm = rng.permutation(n_cols) # column j of shredded is column perm[j] of photo
shredded = photo[:, perm]
F = shredded.transpose(1, 0, 2).reshape(n_cols, -1) # one row per column of pixels
print(F.shape)(451, 900)
The graph and its eigenmap
We join each column to its \(4\) nearest neighbours in \(\Rbb^{900}\), in Euclidean distance, and keep an edge if either end chose the other. The weights are all \(1\). The one-dimensional eigenmap is the single coordinate \(\bfphi_2=\bfD^{-1/2}\bfv_2\), computed exactly as in Lecture 16 but without the constant first column.
def laplacian_eigenmap(W, m):
"""Coordinates phi_2, ..., phi_{m+1} and the eigenvalues lambda_1, ..., lambda_{m+1}."""
d = W.sum(axis=1)
L_sym = np.eye(len(d)) - W / np.sqrt(np.outer(d, d))
lam, V = np.linalg.eigh(L_sym) # increasing order
return lam[:m + 1], V[:, 1:m + 1] / np.sqrt(d)[:, None]
def knn_graph(F, k):
A = kneighbors_graph(F, k, mode="connectivity").toarray()
return np.maximum(A, A.T) # symmetrize: i ~ j if either chose the other
W = knn_graph(F, k=4)
print("connected components:", connected_components(W, directed=False)[0])
lam, Phi = laplacian_eigenmap(W, m=1)
phi = Phi[:, 0]
order = np.argsort(phi) # sort the shredded columns by phi_2
restored = shredded[:, order]
print("lambda_1, lambda_2 =", np.round(lam, 6))connected components: 1
lambda_1, lambda_2 = [0.0e+00 6.6e-05]
Plot the restored photograph
To score the result, we check how many consecutive columns in the restored image were also next to each other in the photograph. The few that were not are swaps of two or three columns, which are invisible.
consecutive columns that were neighbours in the photograph: 96.7%
sizes of the other jumps: [2 3]
Why it works
By the smoothest-embedding theorem, \(\bfphi_2\) is the non-constant function on the vertices with the smallest energy \(\sum_{i<j}w_{ij}(\phi_i-\phi_j)^2\), subject to the same constraints as before. A function has small energy when it changes little across every edge. Our graph is close to a path: each column is joined only to the few columns on either side of it in the photograph. The smoothest non-constant function on a path rises steadily from one end to the other. For the unnormalized Laplacian of a path on \(n\) vertices it is exactly the half cosine \(\cos\big(\pi(j+\tfrac12)/n\big)\), and the normalized one is very close to it. Sorting needs much less than that: any function that increases along the path puts the vertices in path order.
Figure 3 plots \(\phi_2\) against the position each column had in the photograph, which the method never saw.
Plot phi_2 against the original position
j = np.arange(n_cols)
half_cosine = -np.cos(np.pi * (j + 0.5) / n_cols)
sign = np.sign(np.corrcoef(phi, half_cosine[perm])[0, 1])
scale = np.abs(phi).max() / np.abs(half_cosine).max()
fig = go.Figure([
go.Scatter(x=j, y=sign * scale * half_cosine, mode="lines", name="half cosine",
line=dict(color=INK, dash="dash")),
go.Scatter(x=perm, y=phi, mode="markers", name="φ₂", marker=dict(size=5, color=BLUE),
hovertemplate="original column %{x}<br>φ₂ = %{y:.4f}<extra></extra>"),
])
fig.update_xaxes(title="position in the original photograph")
fig.update_yaxes(title="φ₂")
style(fig, height=380).update_layout(legend=dict(x=0.02, y=0.98)).show()Seriation has a second picture. In the shredded order the edges of the graph are scattered all over the adjacency matrix. In the order given by \(\phi_2\), every edge joins two columns that are close in the new order, so the matrix becomes a narrow band along the diagonal.
Plot the adjacency matrix in both orders
Left or right?
If \(\bfphi_2\) is an eigenmap, so is \(-\bfphi_2\), since a Laplacian eigenmap is only determined up to rotation and reflection, and in one dimension that just means the sign. Sorting by \(-\bfphi_2\) gives the mirror image of the photograph. This is not a flaw of the method: the shredded columns carry no information about which way is left, so no method can tell a photograph from its mirror image. Our cat came out the right way round by luck of the sign; with default_rng(3) it comes out mirrored.
When it fails: a brick wall
The method relies on the graph being close to a path, which in turn relies on far-apart columns looking different. A brick wall breaks that assumption. Its pattern repeats, so a column of one brick looks very much like the matching column of a brick far away, and the nearest-neighbour graph joins them.
wall = data.brick() / 255 # (512, 512), greyscale
wall_perm = rng.permutation(wall.shape[1])
wall_shredded = wall[:, wall_perm]
W_wall = knn_graph(wall_shredded.T, k=4)
_, Phi_wall = laplacian_eigenmap(W_wall, m=1)
wall_order = np.argsort(Phi_wall[:, 0])
wall_restored = wall_shredded[:, wall_order]
steps = np.abs(np.diff(wall_perm[wall_order]))
print(f"consecutive columns that were neighbours in the photograph: {np.mean(steps == 1):.1%}")consecutive columns that were neighbours in the photograph: 16.2%
Plot the brick wall
The result is not noise: columns that were neighbours in the photograph are still joined in the graph, so pieces of brick texture reappear. But the graph also has many edges between distant columns, so it is no longer close to a path, and the smoothest function on it has no reason to increase along the original order.