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.
from IPython.display import Image
Image(r'C:\thisAKcode.github.io\images\truchet1.png', width = 200)
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
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)
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()