Image Processing with Maxima¶

This notebook demonstrates image processing using numerics and numerics-image. We load images as ndarrays, apply 2D convolution kernels for blurring, edge detection, and sharpening, and display the results inline.

The numerics-image module provides:

  • np_read_image(path) / np_write_image(A, path) — image I/O via opticl
  • np_imshow(A) — inline display of an ndarray as a PNG image
  • np_mandrill() — bundled 512×512 test image
  • np_to_image(A) / np_from_image(img) — convert between ndarrays and opticl arrays

Convolution uses np_convolve2d(A, kernel) from the core numerics library, which implements "valid" mode: the output shape is $(h - k_h + 1) \times (w - k_w + 1)$.

In [32]:
load("numerics")$
load("numerics-image")$

Loading the Test Image¶

The bundled mandrill (baboon) image is a classic 512×512 RGB test image from the USC-SIPI database. np_mandrill() returns it as an ndarray with shape $[512, 512, 3]$, where pixel values range from 0 to 255.

In [33]:
img : np_mandrill()$
np_shape(img);
WARNING:
   Extension chunk type not implemented: "eXIf" (101 88 73 102) in stream: NIL.
Out[33]:
In [34]:
np_imshow(img)$
No description has been provided for this image

Converting to Grayscale¶

For convolution we need a single-channel 2D array. We extract the R, G, and B channels using np_slice, average them, and reshape to a flat 2D matrix.

The standard luminance formula is $Y = 0.299R + 0.587G + 0.114B$, but a simple average $(R + G + B) / 3$ is sufficient for demonstration.

In [35]:
/* Extract channels: img has shape [512, 512, 3] */
red   : np_slice(img, all, all, [0, 1])$
green : np_slice(img, all, all, [1, 2])$
blue  : np_slice(img, all, all, [2, 3])$

/* Average the three channels */
gray3d : np_scale(1/3, np_add(np_add(red, green), blue))$

/* Reshape from [512, 512, 1] to [512, 512] for convolution */
gray : np_reshape(gray3d, [512, 512])$
np_shape(gray);
Out[35]:
In [36]:
np_imshow(gray)$
No description has been provided for this image

Gaussian-like Blur¶

A box filter (averaging kernel) replaces each pixel with the mean of its neighbourhood. A $5 \times 5$ box filter is:

$$K_{\text{blur}} = \frac{1}{25} \begin{pmatrix} 1 & 1 & 1 & 1 & 1 \\ 1 & 1 & 1 & 1 & 1 \\ 1 & 1 & 1 & 1 & 1 \\ 1 & 1 & 1 & 1 & 1 \\ 1 & 1 & 1 & 1 & 1 \end{pmatrix}$$

For a closer Gaussian approximation we could weight the centre more heavily, but the box filter demonstrates the principle clearly.

In [37]:
/* 5x5 box blur kernel */
blur_kernel : np_scale(1/25, ndarray(
  matrix([1,1,1,1,1],
         [1,1,1,1,1],
         [1,1,1,1,1],
         [1,1,1,1,1],
         [1,1,1,1,1])))$

blurred : np_convolve2d(gray, blur_kernel)$
print("Blurred shape:", np_shape(blurred))$
np_imshow(blurred)$
Blurred shape: [508,508]
No description has been provided for this image

Edge Detection with the Sobel Operator¶

The Sobel operator detects edges by computing approximate horizontal and vertical gradients. The two $3 \times 3$ kernels are:

$$G_x = \begin{pmatrix} -1 & 0 & 1 \\ -2 & 0 & 2 \\ -1 & 0 & 1 \end{pmatrix}, \qquad G_y = \begin{pmatrix} -1 & -2 & -1 \\ 0 & 0 & 0 \\ 1 & 2 & 1 \end{pmatrix}$$

The edge magnitude is $|G| = \sqrt{G_x^2 + G_y^2}$.

In [38]:
/* Sobel kernels */
sobel_x : ndarray(matrix([-1, 0, 1],
                         [-2, 0, 2],
                         [-1, 0, 1]))$

sobel_y : ndarray(matrix([-1, -2, -1],
                         [ 0,  0,  0],
                         [ 1,  2,  1]))$

/* Convolve */
gx : np_convolve2d(gray, sobel_x)$
gy : np_convolve2d(gray, sobel_y)$

/* Edge magnitude: sqrt(gx^2 + gy^2) */
edges : np_sqrt(np_add(np_mul(gx, gx), np_mul(gy, gy)))$
print("Edges shape:", np_shape(edges))$
Edges shape: [510,510]
In [39]:
np_imshow(edges)$
No description has been provided for this image

We can also look at just the horizontal or vertical gradient components separately:

In [40]:
/* Show horizontal edges (absolute value for visibility) */
np_imshow(np_abs(gx))$
No description has been provided for this image
In [41]:
/* Show vertical edges */
np_imshow(np_abs(gy))$
No description has been provided for this image

Sharpening¶

A sharpening filter enhances edges by subtracting a blurred version from the original (unsharp masking). An equivalent single-pass kernel is:

$$K_{\text{sharpen}} = \begin{pmatrix} 0 & -1 & 0 \\ -1 & 5 & -1 \\ 0 & -1 & 0 \end{pmatrix}$$

This is the identity plus a scaled Laplacian. The centre weight of 5 preserves the original pixel value while the negative neighbours subtract the local average.

In [42]:
sharpen_kernel : ndarray(matrix([ 0, -1,  0],
                                [-1,  5, -1],
                                [ 0, -1,  0]))$

sharpened : np_convolve2d(gray, sharpen_kernel)$

/* Clamp to [0, 255] for display */
sharpened : np_clip(sharpened, 0, 255)$
np_imshow(sharpened)$
No description has been provided for this image

Emboss Effect¶

An emboss kernel creates a 3D relief effect by highlighting transitions in one direction:

$$K_{\text{emboss}} = \begin{pmatrix} -2 & -1 & 0 \\ -1 & 1 & 1 \\ 0 & 1 & 2 \end{pmatrix}$$

Adding 128 to the result shifts the neutral grey to mid-range, making the relief visible.

In [43]:
emboss_kernel : ndarray(matrix([-2, -1, 0],
                               [-1,  1, 1],
                               [ 0,  1, 2]))$

embossed : np_convolve2d(gray, emboss_kernel)$

/* Shift to mid-grey and clamp */
embossed : np_clip(np_add(embossed, ndarray([128])), 0, 255)$
np_imshow(embossed)$
No description has been provided for this image

Combining Operations: Edge Overlay¶

We can combine the blurred image with edge detection to create an artistic effect. Here we threshold the Sobel edge magnitude and overlay it on the blurred image.

In [44]:
/* Normalise edges to 0-255 range */
edge_max : np_max(edges)$
edges_norm : np_scale(255.0 / edge_max, edges)$

/* Trim edges (510x510) to match blurred (508x508) */
edges_trimmed : np_slice(edges_norm, [1, 509], [1, 509])$

/* Blend: darken where edges are strong */
blend : np_clip(np_sub(blurred, np_scale(0.5, edges_trimmed)), 0, 255)$
np_imshow(blend)$
No description has been provided for this image

Frequency-Domain Filtering with 2D FFT¶

The 2D discrete Fourier transform decomposes an image into spatial frequency components. Low frequencies encode smooth gradients; high frequencies encode edges and fine detail.

By zeroing out frequency bins we can build simple low-pass and high-pass filters entirely in the frequency domain.

In [45]:
/* 2D FFT of the grayscale mandrill */
F : np_fft2d(gray)$

/* Log-magnitude spectrum for visualisation */
mag : np_abs(F)$
log_mag : np_log(np_add(mag, 1.0))$

/* Scale to 0-255 for display */
lm_max : np_max(log_mag)$
log_mag_img : np_scale(255.0 / lm_max, log_mag)$
np_imshow(log_mag_img)$
No description has been provided for this image

Low-Pass Filter (Blur)¶

A circular low-pass filter keeps only frequencies within radius $r$ of the DC component at index $(0,0)$. Since np_fft2d does not shift the DC to the centre, low frequencies are near the corners of the output array.

In [46]:
/* Build a circular low-pass mask (unshifted layout) */
nr : first(np_shape(gray))$
nc : second(np_shape(gray))$
cutoff : 30$   /* keep frequencies within radius 30 */

/* Frequency indices: wrap around at N/2 for unshifted FFT layout */
row_idx : np_arange(nr)$
col_idx : np_arange(nc)$
fi : np_sub(row_idx, np_scale(float(nr), np_greater_equal(row_idx, nr/2)))$
fj : np_sub(col_idx, np_scale(float(nc), np_greater_equal(col_idx, nc/2)))$

/* Squared distance grid via broadcasting */
fi_sq : np_pow(np_reshape(fi, [nr, 1]), 2)$
fj_sq : np_pow(np_reshape(fj, [1, nc]), 2)$
mask_lp : np_less(np_add(fi_sq, fj_sq), float(cutoff * cutoff))$
print("Low-pass mask built:", np_shape(mask_lp))$
Low-pass mask built: [512,512]
In [47]:
/* Apply low-pass: multiply spectrum by mask, then inverse FFT */
F_lp : np_mul(F, ndarray(np_to_matrix(mask_lp), complex))$
img_lp : np_real(np_ifft2d(F_lp))$
img_lp_clamped : np_clip(img_lp, 0, 255)$
np_imshow(img_lp_clamped)$
No description has been provided for this image

High-Pass Filter (Edge Enhancement)¶

Inverting the mask keeps only high frequencies — the edges and fine textures.

In [48]:
/* High-pass = 1 - low-pass mask */
mask_hp : np_sub(np_ones([nr, nc]), mask_lp)$
F_hp : np_mul(F, ndarray(np_to_matrix(mask_hp), complex))$
img_hp : np_real(np_ifft2d(F_hp))$

/* Rescale for visibility: shift so mean is 128 */
hp_min : np_min(img_hp)$
hp_max : np_max(img_hp)$
img_hp_vis : np_scale(255.0 / (hp_max - hp_min), np_sub(img_hp, hp_min))$
np_imshow(img_hp_vis)$
No description has been provided for this image

Round-Trip Verification¶

The inverse FFT should perfectly reconstruct the original image.

In [49]:
/* Verify: ifft2d(fft2d(gray)) ≈ gray */
recon : np_real(np_ifft2d(F))$
err : np_max(np_abs(np_sub(recon, gray)))$
print("Max reconstruction error:", err)$
Max reconstruction error: 1.7053025658242404e-13

Summary¶

This notebook demonstrated:

  1. Image loading — np_mandrill() returns a bundled test image as an ndarray
  2. Colour to greyscale — channel extraction with np_slice and averaging
  3. 2D convolution — np_convolve2d(A, kernel) with various kernels:
    • Box blur (averaging)
    • Sobel edge detection (horizontal and vertical gradients)
    • Sharpening (identity + Laplacian)
    • Emboss (directional relief)
  4. 2D FFT — np_fft2d / np_ifft2d for frequency-domain filtering:
    • Log-magnitude spectrum visualisation
    • Circular low-pass filter (blur)
    • High-pass filter (edge enhancement)
    • Perfect round-trip reconstruction
  5. Inline display — np_imshow() renders arrays as PNG images