Truchet Tiles

Published: Sat 21 May 2022
By Alex

In art.

Truchet Tiles

Sebastien Truchet figured out how square tiles can be combined to form larger patterns. In the picture below you see tiles of different types to use as input for generating mosaics.

In [1]:
from IPython.display import Image
Image(r'C:\thisAKcode.github.io\images\truchet1.png', width = 200)
Out[1]:

Linear optimisation for mosaicing grayscale images

To make mosaics from a grayscale picture you need to group its pixels into k x k blocks and associate it with a tile. The blocks identified as ordered coordinate pairs (i,j). So the upper left block is (0,0) it's followed by (0,1), (0,2), …across the row. Block (i, j) is the block in row i and column j. For k=3 you have 9 pixels in a block- we compute the average of its 9 pixels' grayscale values and then divide the average by 255. This gives us a block brightness value \beta i, j that falls somewhere in the closed interval [0, 1]. As before, 0 corresponds to black, but now 1 corresponds to white. For block (32, 34), the block in the upper-left corner of the part of the image that corresponds to Truchet's left eye, the average grayscale value is 154.11, so $ \beta_i,_j $ 32,34 = 154.11∕255 = 0.60. For block (36, 39), in the opposite corner, the average grayscale value is 241.67, so $ \beta_i,_j $ 36,39 = 241.67∕255 = 0.95. In other words, block (32, 34) is somewhat brighter

In [18]:
import requests
from PIL import Image
from io import BytesIO
import numpy as np
import matplotlib.pyplot as plt
from skimage.color import rgb2gray
from skimage.filters import threshold_multiotsu


def posterize(img, classes=3):
    gray = rgb2gray(img) if img.ndim == 3 else img
    thresholds = threshold_multiotsu(gray, classes=classes)
    levels = np.digitize(gray, bins=thresholds)
    return (levels * (255 // (classes - 1))).astype(np.uint8)

def show(img, title, cmap=None, colorbar=False):
    plt.figure(figsize=(8, 8))
    im = plt.imshow(img, cmap=cmap)
    plt.title(title); plt.axis('off')
    if colorbar: plt.colorbar(im, fraction=0.03)
    plt.show()

def show_posterize(imgs: list, titles=None, classes=3):
    """imgs: list of numpy arrays (RGB or gray)"""
    n = len(imgs)
    fig, axes = plt.subplots(n, 2, figsize=(8, 4 * n))
    if n == 1:
        axes = [axes]  # normalize shape

    for i, img in enumerate(imgs):
        result = posterize(img, classes)
        axes[i][0].imshow(img, cmap="gray" if img.ndim == 2 else None)
        axes[i][0].set_title(titles[i] if titles else f"Original {i+1}")
        axes[i][1].imshow(result, cmap="gray", vmin=0, vmax=255)
        axes[i][1].set_title(f"Posterized ({classes} levels)")
        for ax in axes[i]:
            ax.axis("off")

    plt.tight_layout()
    plt.show()
davids_head_url = r"https://upload.wikimedia.org/wikipedia/commons/thumb/8/86/%27David%27_by_Michelangelo_Fir_JBU013.jpg/250px-%27David%27_by_Michelangelo_Fir_JBU013.jpg?utm_source=commons.wikimedia.org&utm_campaign=index&utm_content=thumbnail" # Using a more direct URL

# Define headers to mimic a browser request
headers = {
    'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
}

# Download the image with headers
response = requests.get(davids_head_url, headers=headers)
response.raise_for_status() # Check for HTTP errors

img_data = Image.open(BytesIO(response.content))

# Convert to numpy array
davids_head_img = np.array(img_data.convert('L')) # Convert to grayscale for posterize function

show_posterize([davids_head_img], titles=['David\'s Head'], classes=3)



# add code to show processed image here:

from skimage.filters.rank import mean_bilateral  # or scipy
from skimage.exposure import equalize_adapthist

def posterize_photo(img, classes=3, highlight_thresh=0.93):
    gray = rgb2gray(img) if img.ndim == 3 else img.astype(float) / 255

    # 1. Edge-preserving smooth — kills noise, keeps contours sharp
    # Optimized: Reduced sigma_spatial for faster computation
    from skimage.restoration import denoise_bilateral
    smooth = denoise_bilateral(gray, sigma_color=0.01, sigma_spatial=1) # Smaller sigma_spatial

    # 2. CLAHE — lifts midtone separation locally
    # Optimized: Added kernel_size for faster computation
    enhanced = equalize_adapthist(smooth, clip_limit=0.02, kernel_size=(128, 128)) # Smaller kernel_size (default is (8,8) or larger depending on image size)

    # 3. Isolate specular highlights BEFORE Otsu sees them
    highlight_mask = enhanced > highlight_thresh

    # 4. Run Otsu only on non-highlight pixels
    non_highlight = enhanced[~highlight_mask]
    thresholds = threshold_multiotsu(non_highlight, classes=classes - 1)

    # 5. Assemble result
    result = np.digitize(enhanced, bins=thresholds)  # 0, 1, 2
    result[highlight_mask] = classes - 1             # force highlights → white

    return (result * (255 // (classes - 1))).astype(np.uint8)
_tweaked = posterize_photo(davids_head_img, classes=3, highlight_thresh=0.77)


show(_tweaked, 'David\'s Head - Tweaked', cmap="gray", colorbar=False)


from skimage.exposure import adjust_gamma, rescale_intensity
from skimage.filters import gaussian

def posterize_photo2(img, classes=3, gamma=0.8, blur=1.2):
    gray = rgb2gray(img) if img.ndim == 3 else img

    # stretch full tonal range first
    stretched = rescale_intensity(gray)

    # gamma < 1 lifts shadows → better midtone separation
    adjusted = adjust_gamma(stretched, gamma=gamma)

    # cheap noise removal, preserves edges better than bilateral at this sigma
    smoothed = gaussian(adjusted, sigma=blur)

    thresholds = threshold_multiotsu(smoothed, classes=classes)
    result = np.digitize(smoothed, bins=thresholds)
    return (result * (255 // (classes - 1))).astype(np.uint8)
_tweaked = posterize_photo2(davids_head_img, classes=3, gamma=1.2, blur=1.5)


show(_tweaked, 'David\'s Head - Tweaked', cmap="gray", colorbar=False)
_tweaked = posterize_photo2(davids_head_img, classes=3, gamma=0.6, blur=1.5)
In [21]:
from skimage.filters import threshold_multiotsu
from skimage.color import rgb2gray
import numpy as np
import matplotlib.pyplot as plt # Import matplotlib

def stencil_posterize(img, classes=3):
    """Hyper-minimal stencil: light grey → dark grey, 3 classes only"""
    # Normalize to 0-1
    gray = rgb2gray(img) if img.ndim == 3 else img.astype(float) / 255.0

    # Find optimal thresholds
    thresholds = threshold_multiotsu(gray, classes=classes)

    # Digitize to classes [0, 1, 2]
    posterized = np.digitize(gray, bins=thresholds)

    # Map to custom range: light grey (200) → dark grey (50), skip pure white/black
    # Level 0 → 200 (light)
    # Level 1 → 125 (mid)
    # Level 2 → 50  (dark)
    levels = np.array([60, 140, 220], dtype=np.uint8)

    return levels[posterized]

# Test
preresult = posterize(davids_head_img, 3)

result = stencil_posterize(_tweaked)
plt.imshow(result, cmap='gray') # Use plt.imshow
plt.show()
In [ ]:
 
In [ ]:
 

links

social